python-github-plus 1.1.0__py3-none-any.whl → 2.0.0__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.
- python_github_plus/__init__.py +0 -4
- python_github_plus/consts.py +7 -0
- python_github_plus/github_plus.py +236 -135
- python_github_plus-2.0.0.dist-info/METADATA +215 -0
- python_github_plus-2.0.0.dist-info/RECORD +8 -0
- {python_github_plus-1.1.0.dist-info → python_github_plus-2.0.0.dist-info}/WHEEL +1 -1
- python_github_plus/const.py +0 -1
- python_github_plus-1.1.0.dist-info/METADATA +0 -266
- python_github_plus-1.1.0.dist-info/RECORD +0 -8
- {python_github_plus-1.1.0.dist-info → python_github_plus-2.0.0.dist-info}/licenses/LICENSE +0 -0
- {python_github_plus-1.1.0.dist-info → python_github_plus-2.0.0.dist-info}/top_level.txt +0 -0
python_github_plus/__init__.py
CHANGED
|
@@ -1,21 +1,31 @@
|
|
|
1
|
+
import base64
|
|
1
2
|
import builtins
|
|
2
3
|
import os
|
|
3
4
|
import time
|
|
5
|
+
from collections.abc import Iterable
|
|
4
6
|
from enum import Enum
|
|
7
|
+
from itertools import islice
|
|
8
|
+
from typing import TypeVar, get_args
|
|
5
9
|
|
|
6
10
|
from custom_python_logger import get_logger
|
|
7
|
-
from
|
|
11
|
+
from dotenv import load_dotenv
|
|
12
|
+
from github import Auth, Github
|
|
8
13
|
from github.Branch import Branch
|
|
9
14
|
from github.ContentFile import ContentFile
|
|
15
|
+
from github.File import File
|
|
16
|
+
from github.GithubObject import NotSet
|
|
10
17
|
from github.GitRef import GitRef
|
|
11
18
|
from github.GitTag import GitTag
|
|
19
|
+
from github.NamedUser import NamedUser
|
|
12
20
|
from github.PullRequest import PullRequest
|
|
13
21
|
from github.Repository import Repository
|
|
14
22
|
from github.Tag import Tag
|
|
15
23
|
from github.Workflow import Workflow
|
|
16
24
|
from github.WorkflowRun import WorkflowRun
|
|
17
25
|
|
|
18
|
-
from python_github_plus.
|
|
26
|
+
from python_github_plus.consts import DEFAULT_BASE_URL, LOGGER_NAME, MergeMethod, PRState
|
|
27
|
+
|
|
28
|
+
T = TypeVar("T")
|
|
19
29
|
|
|
20
30
|
|
|
21
31
|
class GitHubPRStatus(Enum):
|
|
@@ -30,114 +40,153 @@ class GitHubWorkflowStatus(Enum):
|
|
|
30
40
|
QUEUED = "queued"
|
|
31
41
|
|
|
32
42
|
|
|
33
|
-
|
|
43
|
+
def _take(items: Iterable[T], limit: int | None = None) -> list[T]:
|
|
44
|
+
"""Materialize at most `limit` items. PyGithub fetches pages lazily, so a limit stops paging early."""
|
|
45
|
+
if limit is None:
|
|
46
|
+
return list(items)
|
|
47
|
+
if limit < 1:
|
|
48
|
+
raise ValueError(f"limit must be at least 1, got {limit}")
|
|
49
|
+
return list(islice(items, limit))
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _delete_ref(repo: Repository, ref: str) -> None:
|
|
53
|
+
"""
|
|
54
|
+
Delete a git ref. A lazy GitRef carries the read URL (`git/ref/...`), but DELETE lives at `git/refs/...`,
|
|
55
|
+
so fetch it first: that sets the canonical URL, and a missing ref raises 404 here.
|
|
56
|
+
"""
|
|
57
|
+
git_ref = repo.get_git_ref(ref)
|
|
58
|
+
git_ref.complete()
|
|
59
|
+
git_ref.delete()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class _RepoService:
|
|
34
63
|
def __init__(self, repo: Repository) -> None:
|
|
35
64
|
self.logger = get_logger(LOGGER_NAME)
|
|
36
65
|
self.repo = repo
|
|
37
66
|
|
|
67
|
+
def _ref_or_default(self, ref: str | None) -> str:
|
|
68
|
+
return ref or self.repo.default_branch
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class GitHubProjectService(_RepoService):
|
|
38
72
|
def get_info(self) -> Repository:
|
|
39
73
|
return self.repo
|
|
40
74
|
|
|
41
|
-
def list_members(self) -> list:
|
|
42
|
-
return
|
|
75
|
+
def list_members(self, limit: int | None = None) -> list[NamedUser]:
|
|
76
|
+
return _take(self.repo.get_collaborators(), limit)
|
|
43
77
|
|
|
44
78
|
def add_member(self, username: str, permission: str = "push") -> None:
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
self.logger.info(f"✅ Added member '{username}' with '{permission}' permission.")
|
|
48
|
-
except GithubException as e:
|
|
49
|
-
self.logger.error(f"❌ Failed to add member {username}: {e}")
|
|
79
|
+
self.repo.add_to_collaborators(username, permission)
|
|
80
|
+
self.logger.info(f"✅ Added member '{username}' with '{permission}' permission.")
|
|
50
81
|
|
|
51
82
|
def remove_member(self, username: str) -> None:
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
self.logger.info(f"✅ Removed member '{username}'.")
|
|
55
|
-
except GithubException as e:
|
|
56
|
-
self.logger.error(f"❌ Failed to remove member {username}: {e}")
|
|
83
|
+
self.repo.remove_from_collaborators(username)
|
|
84
|
+
self.logger.info(f"✅ Removed member '{username}'.")
|
|
57
85
|
|
|
58
86
|
|
|
59
|
-
class GitHubWorkflowService:
|
|
60
|
-
def __init__(self, repo: Repository) -> None:
|
|
61
|
-
self.logger = get_logger(LOGGER_NAME)
|
|
62
|
-
self.repo = repo
|
|
63
|
-
|
|
87
|
+
class GitHubWorkflowService(_RepoService):
|
|
64
88
|
def list(self) -> list[Workflow]:
|
|
65
89
|
"""List workflows configured in the repo (.github/workflows)."""
|
|
66
90
|
return list(self.repo.get_workflows())
|
|
67
91
|
|
|
68
|
-
def
|
|
69
|
-
"""
|
|
70
|
-
|
|
71
|
-
|
|
92
|
+
def find(self, workflow: str | int) -> Workflow:
|
|
93
|
+
"""Find a workflow by display name, file name (`ci.yml`), path or id. Names fall back to case-insensitive."""
|
|
94
|
+
key = str(workflow)
|
|
95
|
+
workflows = self.list()
|
|
96
|
+
matchers = (
|
|
97
|
+
lambda wf: wf.name == key,
|
|
98
|
+
lambda wf: key in {wf.path, os.path.basename(wf.path)},
|
|
99
|
+
lambda wf: str(wf.id) == key,
|
|
100
|
+
lambda wf: wf.name.casefold() == key.casefold(),
|
|
101
|
+
)
|
|
102
|
+
for matches in matchers:
|
|
103
|
+
found = [wf for wf in workflows if matches(wf)]
|
|
104
|
+
if len(found) == 1:
|
|
105
|
+
return found[0]
|
|
106
|
+
if len(found) > 1:
|
|
107
|
+
raise ValueError(f"❌ Workflow '{key}' is ambiguous: {[wf.path for wf in found]}")
|
|
108
|
+
available = [f"{wf.name} ({os.path.basename(wf.path)})" for wf in workflows]
|
|
109
|
+
raise ValueError(f"❌ Workflow '{key}' not found. Available: {available}")
|
|
110
|
+
|
|
111
|
+
def trigger(
|
|
112
|
+
self, workflow_name: str | int, branch_name: str | None = None, inputs: dict | None = None
|
|
113
|
+
) -> WorkflowRun | None:
|
|
72
114
|
"""
|
|
73
|
-
|
|
74
|
-
if
|
|
75
|
-
raise ValueError(f"❌ Workflow '{workflow_name}' not found.")
|
|
115
|
+
Dispatch a workflow on a branch (the repo's default branch if not given).
|
|
116
|
+
Requires 'workflow_dispatch' in the workflow YAML. Raises GithubException if GitHub rejects the dispatch.
|
|
76
117
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
118
|
+
Returns the created run when GitHub reports it (github.com does); otherwise None,
|
|
119
|
+
and the run can be found with list_runs.
|
|
120
|
+
"""
|
|
121
|
+
workflow = self.find(workflow_name)
|
|
122
|
+
branch_name = self._ref_or_default(branch_name)
|
|
123
|
+
result = workflow.create_dispatch(ref=branch_name, inputs=inputs or {}, throw=True, return_run_details=True)
|
|
124
|
+
self.logger.info(f"🚀 Triggered workflow '{workflow.name}' on branch '{branch_name}'")
|
|
125
|
+
return result if isinstance(result, WorkflowRun) else None
|
|
83
126
|
|
|
84
127
|
def get_workflow_by_id(self, workflow_id: int) -> Workflow:
|
|
85
128
|
return self.repo.get_workflow(workflow_id)
|
|
86
129
|
|
|
87
|
-
def list_runs(
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
130
|
+
def list_runs(
|
|
131
|
+
self, workflow_name: str | int, branch: str | None = None, limit: int | None = None
|
|
132
|
+
) -> builtins.list[WorkflowRun]:
|
|
133
|
+
"""Runs of a workflow, newest first."""
|
|
134
|
+
workflow = self.find(workflow_name)
|
|
135
|
+
runs = workflow.get_runs(branch=branch) if branch else workflow.get_runs()
|
|
136
|
+
return _take(runs, limit)
|
|
91
137
|
|
|
92
|
-
|
|
93
|
-
return
|
|
138
|
+
def get_run(self, run_id: int) -> WorkflowRun:
|
|
139
|
+
return self.repo.get_workflow_run(run_id)
|
|
94
140
|
|
|
95
|
-
def last_run_by_id(self, workflow_id: int) -> WorkflowRun:
|
|
96
|
-
|
|
141
|
+
def last_run_by_id(self, workflow_id: int) -> WorkflowRun | None:
|
|
142
|
+
"""The newest run of a workflow, or None if it never ran."""
|
|
143
|
+
return next(iter(self.repo.get_workflow(workflow_id).get_runs()), None)
|
|
97
144
|
|
|
98
145
|
def status(self, run_id: int) -> str:
|
|
99
146
|
"""Get the current status of a workflow run. run_id is the ID of the run, not the workflow."""
|
|
100
|
-
|
|
101
|
-
return run.status # "queued", "in_progress", "completed"
|
|
147
|
+
return self.get_run(run_id).status # "queued", "in_progress", "completed"
|
|
102
148
|
|
|
103
149
|
def conclusion(self, run_id: int) -> str | None:
|
|
104
150
|
"""
|
|
105
151
|
Get the conclusion of a completed workflow run.
|
|
106
152
|
None if still running. run_id is the ID of the run, not the workflow.
|
|
107
153
|
"""
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
154
|
+
return self.get_run(run_id).conclusion # "success", "failure", "cancelled", None if still running
|
|
155
|
+
|
|
156
|
+
def wait_until_finished(self, run_id: int, check_interval: float = 10, timeout: float = 600) -> str | None:
|
|
157
|
+
"""Poll a run until it completes and return its conclusion. Raises TimeoutError after `timeout` seconds."""
|
|
158
|
+
if check_interval < 0 or timeout < 0:
|
|
159
|
+
raise ValueError("check_interval and timeout must not be negative")
|
|
160
|
+
deadline = time.monotonic() + timeout
|
|
161
|
+
while True: # pylint: disable=W0149
|
|
162
|
+
run = self.get_run(run_id)
|
|
163
|
+
if run.status == GitHubWorkflowStatus.COMPLETED.value:
|
|
116
164
|
self.logger.info(f"✅ Workflow run {run_id} finished with conclusion = {run.conclusion}")
|
|
117
165
|
return run.conclusion
|
|
166
|
+
if (remaining := deadline - time.monotonic()) <= 0:
|
|
167
|
+
raise TimeoutError(f"⏰ Workflow run {run_id} did not complete within {timeout} seconds.")
|
|
118
168
|
self.logger.debug(f"⏳ Workflow run {run_id} still {run.status}...")
|
|
119
|
-
time.sleep(check_interval)
|
|
120
|
-
raise TimeoutError(f"⏰ Workflow run {run_id} did not complete within {timeout} seconds.")
|
|
169
|
+
time.sleep(min(check_interval, remaining))
|
|
121
170
|
|
|
122
171
|
|
|
123
|
-
class GitHubBranchService:
|
|
124
|
-
def
|
|
125
|
-
self.
|
|
126
|
-
self.repo = repo
|
|
172
|
+
class GitHubBranchService(_RepoService):
|
|
173
|
+
def get(self, branch_name: str) -> Branch:
|
|
174
|
+
return self.repo.get_branch(branch_name)
|
|
127
175
|
|
|
128
|
-
def create(self, branch_name: str, from_branch: str) -> GitRef:
|
|
176
|
+
def create(self, branch_name: str, from_branch: str | None = None) -> GitRef:
|
|
177
|
+
"""Create a branch from another branch (the repo's default branch if not given)."""
|
|
178
|
+
from_branch = self._ref_or_default(from_branch)
|
|
129
179
|
ref = self.repo.get_git_ref(f"heads/{from_branch}")
|
|
130
180
|
new_ref = self.repo.create_git_ref(ref=f"refs/heads/{branch_name}", sha=ref.object.sha)
|
|
131
181
|
self.logger.info(f"✅ Branch '{branch_name}' created from '{from_branch}'")
|
|
132
182
|
return new_ref
|
|
133
183
|
|
|
134
184
|
def delete(self, branch_name: str) -> None:
|
|
135
|
-
|
|
136
|
-
ref.delete()
|
|
185
|
+
_delete_ref(self.repo, f"heads/{branch_name}")
|
|
137
186
|
self.logger.info(f"✅ Branch '{branch_name}' deleted")
|
|
138
187
|
|
|
139
|
-
def list(self) -> list[Branch]:
|
|
140
|
-
return
|
|
188
|
+
def list(self, limit: int | None = None) -> list[Branch]:
|
|
189
|
+
return _take(self.repo.get_branches(), limit)
|
|
141
190
|
|
|
142
191
|
def protect(
|
|
143
192
|
self,
|
|
@@ -146,70 +195,87 @@ class GitHubBranchService:
|
|
|
146
195
|
require_pull_request_reviews: bool = True,
|
|
147
196
|
dismiss_stale_reviews: bool = True,
|
|
148
197
|
required_approving_review_count: int = 1,
|
|
198
|
+
require_code_owner_reviews: bool = False,
|
|
149
199
|
require_status_checks: bool = False,
|
|
150
200
|
status_check_contexts: builtins.list[str] | None = None,
|
|
201
|
+
strict_status_checks: bool = False,
|
|
151
202
|
) -> None:
|
|
152
203
|
"""
|
|
153
204
|
Protect a branch with common settings.
|
|
205
|
+
Review settings apply only when `require_pull_request_reviews` is True,
|
|
206
|
+
status check settings only when `require_status_checks` is True.
|
|
154
207
|
"""
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
208
|
+
kwargs: dict = {"enforce_admins": enforce_admins}
|
|
209
|
+
if require_pull_request_reviews:
|
|
210
|
+
kwargs |= {
|
|
211
|
+
"dismiss_stale_reviews": dismiss_stale_reviews,
|
|
212
|
+
"required_approving_review_count": required_approving_review_count,
|
|
213
|
+
"require_code_owner_reviews": require_code_owner_reviews,
|
|
214
|
+
}
|
|
215
|
+
if require_status_checks:
|
|
216
|
+
kwargs |= {"contexts": status_check_contexts or [], "strict": strict_status_checks}
|
|
217
|
+
self.get(branch_name).edit_protection(**kwargs)
|
|
163
218
|
self.logger.info(f"✅ Branch '{branch_name}' protected")
|
|
164
219
|
|
|
165
220
|
def unprotect(self, branch_name: str) -> None:
|
|
166
|
-
|
|
167
|
-
branch.remove_protection()
|
|
221
|
+
self.get(branch_name).remove_protection()
|
|
168
222
|
self.logger.info(f"✅ Branch '{branch_name}' unprotected")
|
|
169
223
|
|
|
170
224
|
|
|
171
|
-
class GitHubTagService:
|
|
172
|
-
def __init__(self, repo: Repository) -> None:
|
|
173
|
-
self.logger = get_logger(LOGGER_NAME)
|
|
174
|
-
self.repo = repo
|
|
175
|
-
|
|
225
|
+
class GitHubTagService(_RepoService):
|
|
176
226
|
def create_from_sha(self, tag_name: str, sha: str, message: str | None = None) -> GitTag:
|
|
177
|
-
|
|
227
|
+
"""Create an annotated tag pointing at a commit."""
|
|
228
|
+
tag = self.repo.create_git_tag(tag=tag_name, message=message or tag_name, object=sha, type="commit")
|
|
178
229
|
self.repo.create_git_ref(ref=f"refs/tags/{tag_name}", sha=tag.sha)
|
|
179
230
|
self.logger.info(f"✅ Tag '{tag_name}' created at commit {sha}")
|
|
180
231
|
return tag
|
|
181
232
|
|
|
182
|
-
def create(self, tag_name: str, from_branch: str, message: str | None = None) -> GitTag:
|
|
183
|
-
branch
|
|
184
|
-
sha =
|
|
233
|
+
def create(self, tag_name: str, from_branch: str | None = None, message: str | None = None) -> GitTag:
|
|
234
|
+
"""Tag the head of a branch (the repo's default branch if not given)."""
|
|
235
|
+
sha = self.repo.get_branch(self._ref_or_default(from_branch)).commit.sha
|
|
185
236
|
return self.create_from_sha(tag_name=tag_name, sha=sha, message=message)
|
|
186
237
|
|
|
187
238
|
def delete(self, tag_name: str) -> None:
|
|
188
|
-
|
|
189
|
-
ref.delete()
|
|
239
|
+
_delete_ref(self.repo, f"tags/{tag_name}")
|
|
190
240
|
self.logger.info(f"✅ Tag '{tag_name}' deleted")
|
|
191
241
|
|
|
192
|
-
def list(self) -> list[Tag]:
|
|
193
|
-
return
|
|
194
|
-
|
|
242
|
+
def list(self, limit: int | None = None) -> list[Tag]:
|
|
243
|
+
return _take(self.repo.get_tags(), limit)
|
|
195
244
|
|
|
196
|
-
class GitHubPRService:
|
|
197
|
-
def __init__(self, repo: Repository) -> None:
|
|
198
|
-
self.logger = get_logger(LOGGER_NAME)
|
|
199
|
-
self.repo = repo
|
|
200
245
|
|
|
246
|
+
class GitHubPRService(_RepoService):
|
|
201
247
|
def get_info(self, number: int) -> PullRequest:
|
|
202
248
|
return self.repo.get_pull(number)
|
|
203
249
|
|
|
204
|
-
def
|
|
205
|
-
|
|
250
|
+
def list(
|
|
251
|
+
self,
|
|
252
|
+
state: PRState = "open",
|
|
253
|
+
base: str | None = None,
|
|
254
|
+
head: str | None = None,
|
|
255
|
+
limit: int | None = None,
|
|
256
|
+
) -> list[PullRequest]:
|
|
257
|
+
"""Pull requests, newest first. `head` is `owner:branch`, as the GitHub API expects."""
|
|
258
|
+
pulls = self.repo.get_pulls(state=state, base=base or NotSet, head=head or NotSet)
|
|
259
|
+
return _take(pulls, limit)
|
|
260
|
+
|
|
261
|
+
def files(self, number: int, limit: int | None = None) -> builtins.list[File]:
|
|
262
|
+
"""Files changed by a pull request, with their patches."""
|
|
263
|
+
return _take(self.repo.get_pull(number).get_files(), limit)
|
|
264
|
+
|
|
265
|
+
def create(
|
|
266
|
+
self, title: str, from_branch: str, target: str | None = None, body: str = "", draft: bool = False
|
|
267
|
+
) -> PullRequest:
|
|
268
|
+
"""Open a pull request into `target` (the repo's default branch if not given)."""
|
|
269
|
+
pr = self.repo.create_pull(
|
|
270
|
+
title=title, head=from_branch, base=self._ref_or_default(target), body=body, draft=draft
|
|
271
|
+
)
|
|
206
272
|
self.logger.info(f"✅ PR '{title}' created: #{pr.number}")
|
|
207
273
|
return pr
|
|
208
274
|
|
|
209
|
-
def assign(self, pr_number: int, assignees: str) -> None:
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
self.logger.info(f"✅ PR #{pr_number} assigned to {
|
|
275
|
+
def assign(self, pr_number: int, assignees: str | builtins.list[str]) -> None:
|
|
276
|
+
names = [assignees] if isinstance(assignees, str) else assignees
|
|
277
|
+
self.repo.get_issue(pr_number).add_to_assignees(*names) # a PR is also an issue
|
|
278
|
+
self.logger.info(f"✅ PR #{pr_number} assigned to {names}")
|
|
213
279
|
|
|
214
280
|
def status(self, number: int) -> str:
|
|
215
281
|
pr = self.repo.get_pull(number)
|
|
@@ -217,57 +283,61 @@ class GitHubPRService:
|
|
|
217
283
|
return GitHubPRStatus.MERGED.value
|
|
218
284
|
return pr.state
|
|
219
285
|
|
|
220
|
-
def merge(
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
286
|
+
def merge(
|
|
287
|
+
self,
|
|
288
|
+
number: int,
|
|
289
|
+
merge_method: MergeMethod = "merge",
|
|
290
|
+
commit_title: str | None = None,
|
|
291
|
+
commit_message: str | None = None,
|
|
292
|
+
) -> str:
|
|
293
|
+
"""Merge a pull request and return the merge commit sha. Raises ValueError if GitHub did not merge it."""
|
|
294
|
+
if merge_method not in get_args(MergeMethod):
|
|
295
|
+
raise ValueError(f"merge_method must be one of {get_args(MergeMethod)}, got '{merge_method}'")
|
|
296
|
+
result = self.repo.get_pull(number).merge(
|
|
297
|
+
merge_method=merge_method, commit_title=commit_title or NotSet, commit_message=commit_message or NotSet
|
|
298
|
+
)
|
|
299
|
+
if not result.merged:
|
|
300
|
+
raise ValueError(f"❌ PR #{number} was not merged: {result.message}")
|
|
301
|
+
self.logger.info(f"✅ PR #{number} merged ({merge_method}).")
|
|
302
|
+
return result.sha
|
|
224
303
|
|
|
225
304
|
def close(self, number: int) -> None:
|
|
226
|
-
|
|
227
|
-
pr.edit(state="closed")
|
|
305
|
+
self.repo.get_pull(number).edit(state="closed")
|
|
228
306
|
self.logger.info(f"✅ PR #{number} closed.")
|
|
229
307
|
|
|
230
308
|
def reopen(self, number: int) -> None:
|
|
231
|
-
|
|
232
|
-
pr.edit(state="open")
|
|
309
|
+
self.repo.get_pull(number).edit(state="open")
|
|
233
310
|
self.logger.info(f"✅ PR #{number} reopened.")
|
|
234
311
|
|
|
235
312
|
def add_comment(self, number: int, comment: str) -> None:
|
|
236
|
-
|
|
237
|
-
pr.create_issue_comment(comment)
|
|
313
|
+
self.repo.get_pull(number).create_issue_comment(comment)
|
|
238
314
|
self.logger.info(f"✅ Comment added to PR #{number}.")
|
|
239
315
|
|
|
240
316
|
def approve(self, number: int, body: str = "LGTM ✅") -> None:
|
|
241
|
-
|
|
242
|
-
pr.create_review(event="APPROVE", body=body)
|
|
317
|
+
self.repo.get_pull(number).create_review(event="APPROVE", body=body)
|
|
243
318
|
self.logger.info(f"✅ PR #{number} approved.")
|
|
244
319
|
|
|
245
320
|
|
|
246
|
-
class GitHubFileService:
|
|
247
|
-
def
|
|
248
|
-
|
|
249
|
-
self.repo =
|
|
250
|
-
|
|
251
|
-
def get(self, path: str, ref: str = "main") -> ContentFile:
|
|
252
|
-
return self.repo.get_contents(path, ref=ref)
|
|
321
|
+
class GitHubFileService(_RepoService):
|
|
322
|
+
def get(self, path: str, ref: str | None = None) -> ContentFile | list[ContentFile]:
|
|
323
|
+
"""A file, or a directory listing. `ref` defaults to the repo's default branch."""
|
|
324
|
+
return self.repo.get_contents(path, ref=ref or NotSet)
|
|
253
325
|
|
|
254
|
-
def
|
|
255
|
-
|
|
256
|
-
|
|
326
|
+
def list_dir(self, path: str = "", ref: str | None = None) -> list[ContentFile]:
|
|
327
|
+
contents = self.get(path, ref)
|
|
328
|
+
if not isinstance(contents, list):
|
|
329
|
+
raise ValueError(f"❌ '{path}' is a file, not a directory; use fetch_content")
|
|
330
|
+
return contents
|
|
257
331
|
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
# def delete(self, path: str, branch: str, message: str) -> None:
|
|
268
|
-
# file = self.repo.get_contents(path, ref=branch)
|
|
269
|
-
# self.repo.delete_file(path, message, file.sha, branch=branch)
|
|
270
|
-
# self.logger.info(f"✅ File '{path}' deleted on branch '{branch}'")
|
|
332
|
+
def fetch_content(self, file_path: str, ref: str | None = None) -> str:
|
|
333
|
+
"""Text content of a file, decoded as UTF-8. Files over 1 MB are read through the git blob API."""
|
|
334
|
+
file = self.get(file_path, ref)
|
|
335
|
+
if isinstance(file, list):
|
|
336
|
+
raise ValueError(f"❌ '{file_path}' is a directory; use list_dir")
|
|
337
|
+
if file.encoding == "base64":
|
|
338
|
+
return file.decoded_content.decode("utf-8")
|
|
339
|
+
blob = self.repo.get_git_blob(file.sha) # the contents API omits content for files over 1 MB
|
|
340
|
+
return base64.b64decode(blob.content).decode("utf-8")
|
|
271
341
|
|
|
272
342
|
|
|
273
343
|
# ----------------------- #
|
|
@@ -276,12 +346,27 @@ class GitHubFileService:
|
|
|
276
346
|
|
|
277
347
|
|
|
278
348
|
class GitHubClient:
|
|
279
|
-
|
|
349
|
+
"""
|
|
350
|
+
A client for one repository. Construction makes no API calls: the repo is fetched on first use.
|
|
351
|
+
|
|
352
|
+
Use `for_repo` to work with more repositories over the same connection.
|
|
353
|
+
A `github` instance passed in is used as is; create it with `lazy=True` to keep construction free of API calls.
|
|
354
|
+
"""
|
|
355
|
+
|
|
356
|
+
def __init__(
|
|
357
|
+
self,
|
|
358
|
+
repo_full_name: str,
|
|
359
|
+
access_token: str | None = None,
|
|
360
|
+
base_url: str | None = None,
|
|
361
|
+
github: Github | None = None,
|
|
362
|
+
) -> None:
|
|
280
363
|
self.logger = get_logger(LOGGER_NAME)
|
|
281
|
-
|
|
364
|
+
owner, _, name = repo_full_name.partition("/")
|
|
365
|
+
if not owner or not name or "/" in name:
|
|
366
|
+
raise ValueError(f"repo_full_name must be 'owner/name', got '{repo_full_name}'")
|
|
282
367
|
|
|
283
|
-
self.
|
|
284
|
-
self.
|
|
368
|
+
self.repo_full_name = repo_full_name
|
|
369
|
+
self.github = github or self._connect(access_token, base_url)
|
|
285
370
|
|
|
286
371
|
repo = self.github.get_repo(repo_full_name)
|
|
287
372
|
self.project = GitHubProjectService(repo)
|
|
@@ -291,6 +376,22 @@ class GitHubClient:
|
|
|
291
376
|
self.pull_request = GitHubPRService(repo)
|
|
292
377
|
self.file = GitHubFileService(repo)
|
|
293
378
|
|
|
379
|
+
@staticmethod
|
|
380
|
+
def _connect(access_token: str | None, base_url: str | None) -> Github:
|
|
381
|
+
if not access_token or not base_url:
|
|
382
|
+
load_dotenv()
|
|
383
|
+
if not (token := access_token or os.environ.get("GITHUB_ACCESS_TOKEN")):
|
|
384
|
+
raise ValueError("A GitHub token is required: pass access_token or set GITHUB_ACCESS_TOKEN")
|
|
385
|
+
url = base_url or os.environ.get("GITHUB_BASE_URL") or DEFAULT_BASE_URL
|
|
386
|
+
if "://" not in url:
|
|
387
|
+
url = f"https://{url}"
|
|
388
|
+
# lazy: objects (the repo included) are fetched on first use, and every repo shares this requester
|
|
389
|
+
return Github(auth=Auth.Token(token), base_url=url.rstrip("/"), lazy=True)
|
|
390
|
+
|
|
391
|
+
def for_repo(self, repo_full_name: str) -> "GitHubClient":
|
|
392
|
+
"""A client for another repository, sharing this client's connection."""
|
|
393
|
+
return GitHubClient(repo_full_name, github=self.github)
|
|
394
|
+
|
|
294
395
|
def is_connected(self, raise_if_not_connected: bool = False) -> bool:
|
|
295
396
|
try:
|
|
296
397
|
user = self.github.get_user().login
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: python-github-plus
|
|
3
|
+
Version: 2.0.0
|
|
4
|
+
Summary: Enhanced Python GitHub client.
|
|
5
|
+
Author: Avi Zaguri
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/aviz92/python-github-plus
|
|
8
|
+
Project-URL: Repository, https://github.com/aviz92/python-github-plus
|
|
9
|
+
Project-URL: Issues, https://github.com/aviz92/python-github-plus/issues
|
|
10
|
+
Keywords: development,github,client,api,python
|
|
11
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
16
|
+
Requires-Python: >=3.12
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
License-File: LICENSE
|
|
19
|
+
Requires-Dist: custom-python-logger>=4.0.0
|
|
20
|
+
Requires-Dist: pygithub>=2.10.0
|
|
21
|
+
Requires-Dist: python-dotenv>=1.2.1
|
|
22
|
+
Dynamic: license-file
|
|
23
|
+
|
|
24
|
+

|
|
25
|
+

|
|
26
|
+

|
|
27
|
+

|
|
28
|
+

|
|
29
|
+

|
|
30
|
+
|
|
31
|
+
---
|
|
32
|
+
|
|
33
|
+
# Python GitHub Plus
|
|
34
|
+
An enhanced Python client for GitHub built on [PyGithub](https://github.com/PyGithub/PyGithub): one object per repository,
|
|
35
|
+
grouped services, sensible defaults and errors you can act on.
|
|
36
|
+
|
|
37
|
+
---
|
|
38
|
+
|
|
39
|
+
## Features
|
|
40
|
+
- ✅ **Service-based architecture**: `project`, `branch`, `pull_request`, `workflow`, `tag`, `file`
|
|
41
|
+
- ✅ **Lazy connection**: creating a client makes no API calls; one connection serves many repositories (`for_repo`)
|
|
42
|
+
- ✅ **GitHub.com and GitHub Enterprise Server** (`base_url`)
|
|
43
|
+
- ✅ **Errors are raised, not logged and swallowed**: `GithubException` from the API, `ValueError` for bad input
|
|
44
|
+
- ✅ **Default branch aware**: branch, tag, PR, workflow and file calls default to the repo's real default branch
|
|
45
|
+
- ✅ **Bounded listing**: every list method takes `limit` and stops paging once it is reached
|
|
46
|
+
- ✅ **Pull requests**: list, create (body, draft), files, assign, comment, approve, merge (merge/squash/rebase), close
|
|
47
|
+
- ✅ **Workflows**: find by name, file name or id, trigger and get the run back, wait for completion
|
|
48
|
+
- ✅ **Branches and tags**: create, delete, list, protect / unprotect
|
|
49
|
+
- ✅ **Files**: read text files (including files over 1 MB) and list directories
|
|
50
|
+
|
|
51
|
+
---
|
|
52
|
+
|
|
53
|
+
## Installation
|
|
54
|
+
```bash
|
|
55
|
+
pip install python-github-plus
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
---
|
|
59
|
+
|
|
60
|
+
## Configuration
|
|
61
|
+
Pass the token directly, or set environment variables. A `.env` file is loaded only when an argument is missing.
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
GITHUB_ACCESS_TOKEN=your_github_access_token
|
|
65
|
+
GITHUB_BASE_URL=https://github.example.com/api/v3 # optional, GitHub Enterprise Server only
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
---
|
|
69
|
+
|
|
70
|
+
## Examples
|
|
71
|
+
|
|
72
|
+
### Basic Setup and Connection
|
|
73
|
+
```python
|
|
74
|
+
from python_github_plus import GitHubClient
|
|
75
|
+
|
|
76
|
+
github_client = GitHubClient(
|
|
77
|
+
repo_full_name="your-repo-owner/your-repo-name",
|
|
78
|
+
access_token="your_access_token", # or GITHUB_ACCESS_TOKEN
|
|
79
|
+
# base_url="https://github.example.com/api/v3", # GitHub Enterprise Server
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
# Creating the client makes no API calls; check the token explicitly if you want to fail fast
|
|
83
|
+
github_client.is_connected(raise_if_not_connected=True)
|
|
84
|
+
|
|
85
|
+
# Another repository over the same connection
|
|
86
|
+
other_repo = github_client.for_repo("your-repo-owner/another-repo")
|
|
87
|
+
|
|
88
|
+
# Services
|
|
89
|
+
github_client.project
|
|
90
|
+
github_client.branch
|
|
91
|
+
github_client.pull_request
|
|
92
|
+
github_client.workflow
|
|
93
|
+
github_client.tag
|
|
94
|
+
github_client.file
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
### Branch Management
|
|
98
|
+
```python
|
|
99
|
+
# Create a branch from the default branch (or pass from_branch="develop")
|
|
100
|
+
branch_ref = github_client.branch.create(branch_name="feature/new-feature")
|
|
101
|
+
print(f"Created branch: {branch_ref.ref}")
|
|
102
|
+
|
|
103
|
+
# List the first 50 branches
|
|
104
|
+
for branch in github_client.branch.list(limit=50):
|
|
105
|
+
print(f"Branch: {branch.name}")
|
|
106
|
+
|
|
107
|
+
# Protect a branch: 2 approvals, code owners, required CI check
|
|
108
|
+
github_client.branch.protect(
|
|
109
|
+
"main",
|
|
110
|
+
required_approving_review_count=2,
|
|
111
|
+
require_code_owner_reviews=True,
|
|
112
|
+
require_status_checks=True,
|
|
113
|
+
status_check_contexts=["CI"],
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
# Delete a branch
|
|
117
|
+
github_client.branch.delete("feature/old-feature")
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
### Pull Request Management
|
|
121
|
+
```python
|
|
122
|
+
# Open a draft PR into the default branch
|
|
123
|
+
pr = github_client.pull_request.create(
|
|
124
|
+
title="Add new feature",
|
|
125
|
+
from_branch="feature/new-feature",
|
|
126
|
+
body="What changed and why",
|
|
127
|
+
draft=True,
|
|
128
|
+
)
|
|
129
|
+
print(f"Created PR: #{pr.number}")
|
|
130
|
+
|
|
131
|
+
# List open PRs into main, and the files a PR changes
|
|
132
|
+
for open_pr in github_client.pull_request.list(state="open", base="main", limit=20):
|
|
133
|
+
print(f"#{open_pr.number} {open_pr.title}")
|
|
134
|
+
for changed in github_client.pull_request.files(pr.number):
|
|
135
|
+
print(f"{changed.status}: {changed.filename} (+{changed.additions}/-{changed.deletions})")
|
|
136
|
+
|
|
137
|
+
github_client.pull_request.assign(pr.number, ["username"])
|
|
138
|
+
github_client.pull_request.add_comment(pr.number, "Great work!")
|
|
139
|
+
github_client.pull_request.approve(pr.number)
|
|
140
|
+
|
|
141
|
+
# Merge; raises ValueError if GitHub did not merge it
|
|
142
|
+
merge_sha = github_client.pull_request.merge(pr.number, merge_method="squash")
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
### Workflow Operations
|
|
146
|
+
```python
|
|
147
|
+
# Find a workflow by display name, file name or id, and trigger it on the default branch
|
|
148
|
+
run = github_client.workflow.trigger("ci.yml", inputs={"debug": "true"})
|
|
149
|
+
|
|
150
|
+
# github.com returns the new run; on servers that don't, take the newest run instead
|
|
151
|
+
if run is None:
|
|
152
|
+
run = github_client.workflow.list_runs("ci.yml", limit=1)[0]
|
|
153
|
+
|
|
154
|
+
print(f"Run {run.id}: {github_client.workflow.status(run.id)}")
|
|
155
|
+
|
|
156
|
+
# Poll until the run completes (raises TimeoutError after `timeout` seconds)
|
|
157
|
+
conclusion = github_client.workflow.wait_until_finished(run_id=run.id, check_interval=30, timeout=3600)
|
|
158
|
+
print(f"Workflow run finished: {conclusion}")
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
### File Operations
|
|
162
|
+
```python
|
|
163
|
+
# Read a file from the default branch (or pass ref="v1.0.0")
|
|
164
|
+
content = github_client.file.fetch_content("README.md")
|
|
165
|
+
print(content[:100])
|
|
166
|
+
|
|
167
|
+
# List a directory
|
|
168
|
+
for entry in github_client.file.list_dir("python_github_plus"):
|
|
169
|
+
print(f"{entry.type}: {entry.path}")
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
### Tag Management
|
|
173
|
+
```python
|
|
174
|
+
# Annotated tag on the head of the default branch, or on a specific commit
|
|
175
|
+
tag = github_client.tag.create(tag_name="v1.0.0", message="Release version 1.0.0")
|
|
176
|
+
github_client.tag.create_from_sha(tag_name="v1.0.1", sha="abc123...", message="Hotfix")
|
|
177
|
+
|
|
178
|
+
for repo_tag in github_client.tag.list(limit=10):
|
|
179
|
+
print(f"Tag: {repo_tag.name}")
|
|
180
|
+
|
|
181
|
+
github_client.tag.delete("v0.9.0")
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
### Project Management
|
|
185
|
+
```python
|
|
186
|
+
project_info = github_client.project.get_info()
|
|
187
|
+
print(f"Project: {project_info.full_name} (default branch: {project_info.default_branch})")
|
|
188
|
+
|
|
189
|
+
for member in github_client.project.list_members(limit=100):
|
|
190
|
+
print(f"Member: {member.login}")
|
|
191
|
+
|
|
192
|
+
github_client.project.add_member("newuser", permission="push") # pull, triage, push, maintain or admin
|
|
193
|
+
github_client.project.remove_member("olduser")
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
---
|
|
197
|
+
|
|
198
|
+
## Upgrading from 1.x
|
|
199
|
+
See [CHANGELOG.md](CHANGELOG.md). In short: errors now raise, defaults follow the repo's default branch,
|
|
200
|
+
`workflow.trigger` returns the run, and `pull_request.merge` returns the merge commit sha.
|
|
201
|
+
|
|
202
|
+
---
|
|
203
|
+
|
|
204
|
+
## 🤝 Contributing
|
|
205
|
+
If you have a helpful tool, pattern, or improvement to suggest:
|
|
206
|
+
Fork the repo <br>
|
|
207
|
+
Create a new branch <br>
|
|
208
|
+
Submit a pull request <br>
|
|
209
|
+
I welcome additions that promote clean, productive, and maintainable development. <br>
|
|
210
|
+
|
|
211
|
+
---
|
|
212
|
+
|
|
213
|
+
## 🙏 Thanks
|
|
214
|
+
Thanks for exploring this repository! <br>
|
|
215
|
+
Happy coding! <br>
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
python_github_plus/__init__.py,sha256=srIfJYUg3QU_utYCy4Pm3WqWvApSP3SGQ2wRqQK1Yvw,164
|
|
2
|
+
python_github_plus/consts.py,sha256=eCsgLF2Wo-l7REjNBPU6P9SvKTBAv4bfgzCp0oVS4qQ,202
|
|
3
|
+
python_github_plus/github_plus.py,sha256=n9NHY3l7Zqq51vANKaszASpZi2UQjU3u-RB8i5gbUR8,17597
|
|
4
|
+
python_github_plus-2.0.0.dist-info/licenses/LICENSE,sha256=cSikHY6SZFsPZSBizCDAJ0-Bjjzxt-JtX6TVbKxwimo,1067
|
|
5
|
+
python_github_plus-2.0.0.dist-info/METADATA,sha256=N94vEl1duoOqcipsHQYpxLpVoEKwk5sgZIu8Su9USsQ,7512
|
|
6
|
+
python_github_plus-2.0.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
7
|
+
python_github_plus-2.0.0.dist-info/top_level.txt,sha256=lm_rQAamcpxcN00h3Thamd05_z5PdTfy-RVSuCpN_xA,19
|
|
8
|
+
python_github_plus-2.0.0.dist-info/RECORD,,
|
python_github_plus/const.py
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
LOGGER_NAME = "pytest_depends_on"
|
|
@@ -1,266 +0,0 @@
|
|
|
1
|
-
Metadata-Version: 2.4
|
|
2
|
-
Name: python-github-plus
|
|
3
|
-
Version: 1.1.0
|
|
4
|
-
Summary: Enhanced Python GitHub client.
|
|
5
|
-
Author: Avi Zaguri
|
|
6
|
-
License: MIT
|
|
7
|
-
Project-URL: Homepage, https://github.com/aviz92/python-github-plus
|
|
8
|
-
Project-URL: Repository, https://github.com/aviz92/python-github-plus
|
|
9
|
-
Project-URL: Issues, https://github.com/aviz92/python-github-plus/issues
|
|
10
|
-
Keywords: development,github,client,api,python
|
|
11
|
-
Classifier: Development Status :: 5 - Production/Stable
|
|
12
|
-
Classifier: Intended Audience :: Developers
|
|
13
|
-
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
-
Classifier: Operating System :: OS Independent
|
|
15
|
-
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
16
|
-
Requires-Python: >=3.12
|
|
17
|
-
Description-Content-Type: text/markdown
|
|
18
|
-
License-File: LICENSE
|
|
19
|
-
Requires-Dist: custom-python-logger>=4.0.0
|
|
20
|
-
Requires-Dist: pre-commit>=4.5.0
|
|
21
|
-
Requires-Dist: pygithub>=2.8.1
|
|
22
|
-
Requires-Dist: pytest>=9.0.1
|
|
23
|
-
Requires-Dist: python-dotenv>=1.2.1
|
|
24
|
-
Requires-Dist: setuptools>=80.9.0
|
|
25
|
-
Requires-Dist: wheel>=0.45.1
|
|
26
|
-
Dynamic: license-file
|
|
27
|
-
|
|
28
|
-

|
|
29
|
-

|
|
30
|
-

|
|
31
|
-

|
|
32
|
-

|
|
33
|
-

|
|
34
|
-
|
|
35
|
-
---
|
|
36
|
-
|
|
37
|
-
# Python GitHub Plus
|
|
38
|
-
An enhanced Python client for GitHub that extends the functionality of the official `pygithub` package, providing better error handling, merge request management, branch operations, and more.
|
|
39
|
-
|
|
40
|
-
---
|
|
41
|
-
|
|
42
|
-
## Features
|
|
43
|
-
- ✅ Simplified connection to GitHub instances
|
|
44
|
-
- ✅ Robust error handling with comprehensive logging
|
|
45
|
-
- ✅ **Service-based architecture** for organized functionality
|
|
46
|
-
- ✅ **Project management** (members, info, etc.)
|
|
47
|
-
- ✅ **Branch operations** (create, delete, protect, list, etc.)
|
|
48
|
-
- ✅ **Pull Request management** (create, merge, approve, assign, comment, etc.)
|
|
49
|
-
- ✅ **Workflow operations** (trigger, monitor, wait for completion, etc.)
|
|
50
|
-
- ✅ **Tag management** (create, delete, list, etc.)
|
|
51
|
-
- ✅ **File operations** (read, create, update, delete, etc.)
|
|
52
|
-
|
|
53
|
-
---
|
|
54
|
-
|
|
55
|
-
## Installation
|
|
56
|
-
```bash
|
|
57
|
-
pip install python-github-plus
|
|
58
|
-
```
|
|
59
|
-
|
|
60
|
-
---
|
|
61
|
-
|
|
62
|
-
## Configuration
|
|
63
|
-
The package uses environment variables for authentication and configuration:
|
|
64
|
-
|
|
65
|
-
```bash
|
|
66
|
-
# Required environment variables
|
|
67
|
-
GitHub_ACCESS_TOKEN=your_github_access_token
|
|
68
|
-
GitHub_URL=https://github.com # Your GitHub instance URL (default: github.com)
|
|
69
|
-
```
|
|
70
|
-
|
|
71
|
-
## Examples
|
|
72
|
-
|
|
73
|
-
### Basic Setup and Connection
|
|
74
|
-
```python
|
|
75
|
-
from python_github_plus import GitHubClient
|
|
76
|
-
|
|
77
|
-
# Initialize GitLab client with service-based architecture
|
|
78
|
-
github_client = GitHubClient(
|
|
79
|
-
access_token="your_access_token", # Note: parameter name changed
|
|
80
|
-
repo_full_name="your-repo-owner/your-repo-name",
|
|
81
|
-
)
|
|
82
|
-
|
|
83
|
-
# Access different services
|
|
84
|
-
project_service = github_client.project
|
|
85
|
-
branch_service = github_client.branch
|
|
86
|
-
pr_service = github_client.pull_request
|
|
87
|
-
workflows_service = github_client.workflows
|
|
88
|
-
tag_service = github_client.tag
|
|
89
|
-
file_service = github_client.file
|
|
90
|
-
```
|
|
91
|
-
|
|
92
|
-
### Branch Management
|
|
93
|
-
```python
|
|
94
|
-
from python_github_plus import GitHubClient
|
|
95
|
-
|
|
96
|
-
github_client = GitHubClient(
|
|
97
|
-
access_token="your_access_token", # Note: parameter name changed
|
|
98
|
-
repo_full_name="your-repo-owner/your-repo-name",
|
|
99
|
-
)
|
|
100
|
-
|
|
101
|
-
# Create a new branch
|
|
102
|
-
branch = github_client.branch.create(
|
|
103
|
-
branch_name="feature/new-feature",
|
|
104
|
-
from_branch="main"
|
|
105
|
-
)
|
|
106
|
-
print(f"Created branch: {branch.ref}")
|
|
107
|
-
|
|
108
|
-
# List branches
|
|
109
|
-
branches = github_client.branch.list()
|
|
110
|
-
for branch in branches:
|
|
111
|
-
print(f"Branch: {branch.name}")
|
|
112
|
-
|
|
113
|
-
# Protect a branch
|
|
114
|
-
github_client.branch.protect("main")
|
|
115
|
-
|
|
116
|
-
# Delete a branch
|
|
117
|
-
github_client.branch.delete("feature/old-feature")
|
|
118
|
-
```
|
|
119
|
-
|
|
120
|
-
### Pull Request Management
|
|
121
|
-
```python
|
|
122
|
-
from python_github_plus import GitHubClient
|
|
123
|
-
|
|
124
|
-
github_client = GitHubClient(
|
|
125
|
-
access_token="your_access_token", # Note: parameter name changed
|
|
126
|
-
repo_full_name="your-repo-owner/your-repo-name",
|
|
127
|
-
)
|
|
128
|
-
|
|
129
|
-
# Create a pull request
|
|
130
|
-
pr = github_client.pull_request.create(
|
|
131
|
-
title="Add new feature",
|
|
132
|
-
from_branch="feature/new-feature",
|
|
133
|
-
target="main"
|
|
134
|
-
)
|
|
135
|
-
print(f"Created PR: !{pr.number}")
|
|
136
|
-
|
|
137
|
-
# Assign PR to a user
|
|
138
|
-
github_client.pull_request.assign(pr.number, "username")
|
|
139
|
-
|
|
140
|
-
# Add a comment
|
|
141
|
-
github_client.pull_request.add_comment(pr.number, "Great work!")
|
|
142
|
-
|
|
143
|
-
# Approve the PR
|
|
144
|
-
github_client.pull_request.approve(pr.number)
|
|
145
|
-
|
|
146
|
-
# Merge the PR
|
|
147
|
-
github_client.pull_request.merge(pr.number)
|
|
148
|
-
```
|
|
149
|
-
|
|
150
|
-
### Workflow Operations
|
|
151
|
-
|
|
152
|
-
```python
|
|
153
|
-
import time
|
|
154
|
-
from python_github_plus import GitHubClient
|
|
155
|
-
|
|
156
|
-
github_client = GitHubClient(
|
|
157
|
-
access_token="your_access_token", # Note: parameter name changed
|
|
158
|
-
repo_full_name="your-repo-owner/your-repo-name",
|
|
159
|
-
)
|
|
160
|
-
|
|
161
|
-
# Trigger a workflows
|
|
162
|
-
workflows = github_client.workflow.trigger(
|
|
163
|
-
workflow_name="CI",
|
|
164
|
-
branch_name="main",
|
|
165
|
-
)
|
|
166
|
-
print(f"Workflow triggered: {workflows.id}")
|
|
167
|
-
|
|
168
|
-
time.sleep(5) # wait a bit for the workflow to register
|
|
169
|
-
run_id = github_client.workflow.last_run_by_id(workflow_id=workflows.id)
|
|
170
|
-
|
|
171
|
-
# Check workflow run status
|
|
172
|
-
status = github_client.workflow.status(run_id=run_id.id)
|
|
173
|
-
print(f"Workflow run status: {status}")
|
|
174
|
-
|
|
175
|
-
# Wait for workflows completion
|
|
176
|
-
final_status = github_client.workflow.wait_until_finished(
|
|
177
|
-
run_id=run_id.id,
|
|
178
|
-
check_interval=30,
|
|
179
|
-
timeout=3600
|
|
180
|
-
)
|
|
181
|
-
print(f"Workflow run completed with status: {final_status}")
|
|
182
|
-
```
|
|
183
|
-
|
|
184
|
-
### File Operations
|
|
185
|
-
```python
|
|
186
|
-
from python_github_plus import GitHubClient
|
|
187
|
-
|
|
188
|
-
github_client = GitHubClient(
|
|
189
|
-
access_token="your_access_token", # Note: parameter name changed
|
|
190
|
-
repo_full_name="your-repo-owner/your-repo-name",
|
|
191
|
-
)
|
|
192
|
-
|
|
193
|
-
# Read file content
|
|
194
|
-
file_content = github_client.file.fetch_content(
|
|
195
|
-
file_path="README.md",
|
|
196
|
-
ref="main"
|
|
197
|
-
)
|
|
198
|
-
print(f"File content: {file_content[:100]}...")
|
|
199
|
-
```
|
|
200
|
-
|
|
201
|
-
### Tag Management
|
|
202
|
-
```python
|
|
203
|
-
from python_github_plus import GitHubClient
|
|
204
|
-
|
|
205
|
-
github_client = GitHubClient(
|
|
206
|
-
access_token="your_access_token", # Note: parameter name changed
|
|
207
|
-
repo_full_name="your-repo-owner/your-repo-name",
|
|
208
|
-
)
|
|
209
|
-
|
|
210
|
-
# Create a tag
|
|
211
|
-
tag = github_client.tag.create(
|
|
212
|
-
tag_name="v1.0.0",
|
|
213
|
-
from_branch="main",
|
|
214
|
-
message="Release version 1.0.0"
|
|
215
|
-
)
|
|
216
|
-
print(f"Created tag: {tag.name}")
|
|
217
|
-
|
|
218
|
-
# List tags
|
|
219
|
-
tags = github_client.tag.list()
|
|
220
|
-
for tag in tags:
|
|
221
|
-
print(f"Tag: {tag.name}")
|
|
222
|
-
|
|
223
|
-
# Delete a tag
|
|
224
|
-
github_client.tag.delete("v0.9.0")
|
|
225
|
-
```
|
|
226
|
-
|
|
227
|
-
### Project Management
|
|
228
|
-
```python
|
|
229
|
-
from python_github_plus import GitHubClient
|
|
230
|
-
|
|
231
|
-
github_client = GitHubClient(
|
|
232
|
-
access_token="your_access_token", # Note: parameter name changed
|
|
233
|
-
repo_full_name="your-repo-owner/your-repo-name",
|
|
234
|
-
)
|
|
235
|
-
|
|
236
|
-
# Get project information
|
|
237
|
-
project_info = github_client.project.get_info()
|
|
238
|
-
print(f"Project: {project_info.name}")
|
|
239
|
-
print(f"Description: {project_info.description}")
|
|
240
|
-
|
|
241
|
-
# List project members
|
|
242
|
-
members = github_client.project.list_members()
|
|
243
|
-
for member in members:
|
|
244
|
-
print(f"Member: {member.username}")
|
|
245
|
-
|
|
246
|
-
# Add a member
|
|
247
|
-
github_client.project.add_member("newuser", 30) # 30 = Developer access level
|
|
248
|
-
|
|
249
|
-
# Remove a member
|
|
250
|
-
github_client.project.remove_member("olduser")
|
|
251
|
-
```
|
|
252
|
-
|
|
253
|
-
---
|
|
254
|
-
|
|
255
|
-
## 🤝 Contributing
|
|
256
|
-
If you have a helpful tool, pattern, or improvement to suggest:
|
|
257
|
-
Fork the repo <br>
|
|
258
|
-
Create a new branch <br>
|
|
259
|
-
Submit a pull request <br>
|
|
260
|
-
I welcome additions that promote clean, productive, and maintainable development. <br>
|
|
261
|
-
|
|
262
|
-
---
|
|
263
|
-
|
|
264
|
-
## 🙏 Thanks
|
|
265
|
-
Thanks for exploring this repository! <br>
|
|
266
|
-
Happy coding! <br>
|
|
@@ -1,8 +0,0 @@
|
|
|
1
|
-
python_github_plus/__init__.py,sha256=X9i7u5Or-dmMMWc2R9Vq982MjK-BGpPbvSs3yL7i_ew,211
|
|
2
|
-
python_github_plus/const.py,sha256=lS3kH5jzxhNlJf_jfvH3bc98jQGmpnHoDkJGdj7jLzI,34
|
|
3
|
-
python_github_plus/github_plus.py,sha256=zESx4F3bS3kKKyo5MvL8H66Uxoh_KEteXaFwLQkNfE8,12095
|
|
4
|
-
python_github_plus-1.1.0.dist-info/licenses/LICENSE,sha256=cSikHY6SZFsPZSBizCDAJ0-Bjjzxt-JtX6TVbKxwimo,1067
|
|
5
|
-
python_github_plus-1.1.0.dist-info/METADATA,sha256=RSkzt2JwQdf_NjWAF7DoYv_5it1cIJXipzc7Zafu0CY,7311
|
|
6
|
-
python_github_plus-1.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
7
|
-
python_github_plus-1.1.0.dist-info/top_level.txt,sha256=lm_rQAamcpxcN00h3Thamd05_z5PdTfy-RVSuCpN_xA,19
|
|
8
|
-
python_github_plus-1.1.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|