github-rest-api 0.47.2__py3-none-any.whl → 0.49.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.
- github_rest_api/__init__.py +8 -0
- github_rest_api/github.py +546 -31
- github_rest_api/scripts/container/build_container_images.py +6 -0
- github_rest_api/scripts/github/auto_merge_pull_request.py +3 -2
- github_rest_api/scripts/github/create_github_repo.py +73 -18
- github_rest_api/scripts/github/create_pull_request.py +10 -1
- github_rest_api/scripts/utils.py +3 -0
- github_rest_api/utils.py +19 -0
- {github_rest_api-0.47.2.dist-info → github_rest_api-0.49.0.dist-info}/METADATA +1 -1
- {github_rest_api-0.47.2.dist-info → github_rest_api-0.49.0.dist-info}/RECORD +12 -12
- {github_rest_api-0.47.2.dist-info → github_rest_api-0.49.0.dist-info}/WHEEL +0 -0
- {github_rest_api-0.47.2.dist-info → github_rest_api-0.49.0.dist-info}/entry_points.txt +0 -0
github_rest_api/__init__.py
CHANGED
|
@@ -1,19 +1,27 @@
|
|
|
1
1
|
"""GitHub REST APIs."""
|
|
2
2
|
|
|
3
3
|
from .github import (
|
|
4
|
+
UNSET,
|
|
5
|
+
IssueState,
|
|
6
|
+
IssueStateReason,
|
|
4
7
|
MergeMethod,
|
|
5
8
|
Organization,
|
|
6
9
|
Repository,
|
|
7
10
|
RepositoryType,
|
|
8
11
|
SecretVisibility,
|
|
12
|
+
Unset,
|
|
9
13
|
User,
|
|
10
14
|
)
|
|
11
15
|
|
|
12
16
|
__all__ = [
|
|
17
|
+
"UNSET",
|
|
18
|
+
"IssueState",
|
|
19
|
+
"IssueStateReason",
|
|
13
20
|
"MergeMethod",
|
|
14
21
|
"Organization",
|
|
15
22
|
"Repository",
|
|
16
23
|
"RepositoryType",
|
|
17
24
|
"SecretVisibility",
|
|
25
|
+
"Unset",
|
|
18
26
|
"User",
|
|
19
27
|
]
|
github_rest_api/github.py
CHANGED
|
@@ -20,20 +20,27 @@ from github_rest_api.pr_content import (
|
|
|
20
20
|
deterministic_title,
|
|
21
21
|
generate_pr_content,
|
|
22
22
|
)
|
|
23
|
+
from github_rest_api.utils import as_str_sequence
|
|
23
24
|
|
|
24
25
|
logger = logging.getLogger(__name__)
|
|
25
26
|
|
|
26
27
|
URL_API = "https://api.github.com"
|
|
27
28
|
|
|
29
|
+
# Default connect/read timeout (in seconds) for a GitHub REST API request. It is a
|
|
30
|
+
# per-socket-operation deadline rather than a total-transfer one, so a slow but
|
|
31
|
+
# progressing response is not cut off. Callers may pass their own `timeout` to any
|
|
32
|
+
# of the request helpers, but never `None`: see `_apply_timeout`.
|
|
33
|
+
DEFAULT_TIMEOUT = 10
|
|
34
|
+
|
|
28
35
|
# Default LiteLLM 'provider/model' used to generate PR titles and descriptions.
|
|
29
36
|
DEFAULT_PR_MODEL = "anthropic/claude-haiku-4-5-20251001"
|
|
30
37
|
|
|
31
38
|
# Defaults for automatic pull request merging (see Repository.auto_merge_pull_requests).
|
|
32
39
|
# A marker comment from an allowlisted approver is accepted as an approval signal
|
|
33
40
|
# for AI reviewers that only comment instead of submitting a formal review.
|
|
34
|
-
DEFAULT_AUTO_MERGE_MARKER = "
|
|
41
|
+
DEFAULT_AUTO_MERGE_MARKER = "AUTO_MERGE_APPROVED"
|
|
35
42
|
# Conventional-commit title types eligible for auto-merge.
|
|
36
|
-
DEFAULT_AUTO_MERGE_TYPES = ("chore", "docs", "deps")
|
|
43
|
+
DEFAULT_AUTO_MERGE_TYPES = ("build", "chore", "ci", "docs", "deps")
|
|
37
44
|
# A PR is only auto-merged once its head commit is at least this old, an anti-race
|
|
38
45
|
# guard so a PR that momentarily reads `clean` before CI registers is not merged.
|
|
39
46
|
# A just-created PR is simply deferred to the next run, never dropped, so a small
|
|
@@ -66,6 +73,25 @@ def _validate_secret_name(name: str) -> None:
|
|
|
66
73
|
)
|
|
67
74
|
|
|
68
75
|
|
|
76
|
+
def _apply_timeout(kwargs: dict[str, Any]) -> None:
|
|
77
|
+
"""Apply the default request timeout in place, rejecting an infinite one.
|
|
78
|
+
|
|
79
|
+
`requests` treats `timeout=None` as "wait forever", which can hang a caller
|
|
80
|
+
indefinitely on an unresponsive endpoint. No GitHub REST API call is worth
|
|
81
|
+
that, so `None` is rejected rather than silently replaced: a caller that
|
|
82
|
+
passed it meant to disable the deadline and should say what it wants instead.
|
|
83
|
+
|
|
84
|
+
:param kwargs: Keyword arguments destined for a `requests` call. A `timeout`
|
|
85
|
+
entry is added when absent; any other entry is left untouched.
|
|
86
|
+
:raises ValueError: If `timeout` is explicitly None.
|
|
87
|
+
"""
|
|
88
|
+
if kwargs.setdefault("timeout", DEFAULT_TIMEOUT) is None:
|
|
89
|
+
raise ValueError(
|
|
90
|
+
"timeout=None would wait indefinitely; pass a positive number of "
|
|
91
|
+
f"seconds, or omit timeout to use the default of {DEFAULT_TIMEOUT}."
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
69
95
|
def _encrypt_secret(public_key: str, value: str) -> str:
|
|
70
96
|
"""Encrypt a secret value using a LibSodium sealed box.
|
|
71
97
|
:param public_key: The base64-encoded public key to encrypt against.
|
|
@@ -177,8 +203,15 @@ def _field_gate_failure(
|
|
|
177
203
|
return "it is a draft"
|
|
178
204
|
if not _in_allowlist(_login(pr), _normalized_logins(authors)):
|
|
179
205
|
return "its author is not in the allowlist"
|
|
180
|
-
|
|
181
|
-
|
|
206
|
+
title = pr.get("title") or ""
|
|
207
|
+
if not _title_type_allowed(title, allowed_types):
|
|
208
|
+
type_ = _conventional_type(title)
|
|
209
|
+
current = f"'{type_}'" if type_ is not None else "missing/unrecognized"
|
|
210
|
+
eligible = ", ".join(allowed_types) or "none"
|
|
211
|
+
return (
|
|
212
|
+
f"its title type ({current}) is not auto-merge eligible "
|
|
213
|
+
f"(eligible types: {eligible})"
|
|
214
|
+
)
|
|
182
215
|
return None
|
|
183
216
|
|
|
184
217
|
|
|
@@ -277,12 +310,13 @@ class GitHub:
|
|
|
277
310
|
:param url: The endpoint URL to request.
|
|
278
311
|
:param raise_for_status: Whether to raise on a non-2xx response.
|
|
279
312
|
:param kwargs: Additional keyword arguments (e.g. `params`) forwarded
|
|
280
|
-
to `requests.get`.
|
|
313
|
+
to `requests.get`. `timeout` defaults to `DEFAULT_TIMEOUT`
|
|
314
|
+
seconds and must not be None (see `_apply_timeout`).
|
|
281
315
|
"""
|
|
316
|
+
_apply_timeout(kwargs)
|
|
282
317
|
resp = requests.get(
|
|
283
318
|
url=url,
|
|
284
319
|
headers=self._headers,
|
|
285
|
-
timeout=10,
|
|
286
320
|
**kwargs,
|
|
287
321
|
)
|
|
288
322
|
if raise_for_status:
|
|
@@ -297,22 +331,37 @@ class GitHub:
|
|
|
297
331
|
:param headers: Request headers; defaults to the standard auth headers.
|
|
298
332
|
:param raise_for_status: Whether to raise on a non-2xx response.
|
|
299
333
|
:param kwargs: Additional keyword arguments (e.g. `json`) forwarded
|
|
300
|
-
to `requests.post`.
|
|
334
|
+
to `requests.post`. `timeout` defaults to `DEFAULT_TIMEOUT`
|
|
335
|
+
seconds and must not be None (see `_apply_timeout`).
|
|
301
336
|
"""
|
|
302
337
|
if headers is None:
|
|
303
338
|
headers = self._headers
|
|
339
|
+
_apply_timeout(kwargs)
|
|
304
340
|
resp = requests.post(
|
|
305
341
|
url=url,
|
|
306
342
|
headers=headers,
|
|
307
|
-
timeout=10,
|
|
308
343
|
**kwargs,
|
|
309
344
|
)
|
|
310
345
|
if raise_for_status:
|
|
311
346
|
resp.raise_for_status()
|
|
312
347
|
return resp
|
|
313
348
|
|
|
314
|
-
def _delete(
|
|
315
|
-
|
|
349
|
+
def _delete(
|
|
350
|
+
self, url, raise_for_status: bool = True, **kwargs
|
|
351
|
+
) -> requests.Response:
|
|
352
|
+
"""Send a DELETE request to a GitHub REST API endpoint.
|
|
353
|
+
:param url: The endpoint URL to request.
|
|
354
|
+
:param raise_for_status: Whether to raise on a non-2xx response.
|
|
355
|
+
:param kwargs: Additional keyword arguments (e.g. `json`) forwarded
|
|
356
|
+
to `requests.delete`. `timeout` defaults to `DEFAULT_TIMEOUT`
|
|
357
|
+
seconds and must not be None (see `_apply_timeout`).
|
|
358
|
+
"""
|
|
359
|
+
_apply_timeout(kwargs)
|
|
360
|
+
resp = requests.delete(
|
|
361
|
+
url=url,
|
|
362
|
+
headers=self._headers,
|
|
363
|
+
**kwargs,
|
|
364
|
+
)
|
|
316
365
|
if raise_for_status:
|
|
317
366
|
resp.raise_for_status()
|
|
318
367
|
return resp
|
|
@@ -324,12 +373,13 @@ class GitHub:
|
|
|
324
373
|
:param url: The endpoint URL to request.
|
|
325
374
|
:param raise_for_status: Whether to raise on a non-2xx response.
|
|
326
375
|
:param kwargs: Additional keyword arguments (e.g. `json`) forwarded
|
|
327
|
-
to `requests.put`.
|
|
376
|
+
to `requests.put`. `timeout` defaults to `DEFAULT_TIMEOUT`
|
|
377
|
+
seconds and must not be None (see `_apply_timeout`).
|
|
328
378
|
"""
|
|
379
|
+
_apply_timeout(kwargs)
|
|
329
380
|
resp = requests.put(
|
|
330
381
|
url=url,
|
|
331
382
|
headers=self._headers,
|
|
332
|
-
timeout=10,
|
|
333
383
|
**kwargs,
|
|
334
384
|
)
|
|
335
385
|
if raise_for_status:
|
|
@@ -341,12 +391,13 @@ class GitHub:
|
|
|
341
391
|
:param url: The endpoint URL to request.
|
|
342
392
|
:param raise_for_status: Whether to raise on a non-2xx response.
|
|
343
393
|
:param kwargs: Additional keyword arguments (e.g. `json`) forwarded
|
|
344
|
-
to `requests.patch`.
|
|
394
|
+
to `requests.patch`. `timeout` defaults to `DEFAULT_TIMEOUT`
|
|
395
|
+
seconds and must not be None (see `_apply_timeout`).
|
|
345
396
|
"""
|
|
397
|
+
_apply_timeout(kwargs)
|
|
346
398
|
resp = requests.patch(
|
|
347
399
|
url=url,
|
|
348
400
|
headers=self._headers,
|
|
349
|
-
timeout=10,
|
|
350
401
|
**kwargs,
|
|
351
402
|
)
|
|
352
403
|
if raise_for_status:
|
|
@@ -391,12 +442,56 @@ class IssueState(StrEnum):
|
|
|
391
442
|
ALL = "all"
|
|
392
443
|
|
|
393
444
|
|
|
445
|
+
class IssueStateReason(StrEnum):
|
|
446
|
+
"""Why an issue was closed or reopened, accompanying an ``IssueState``."""
|
|
447
|
+
|
|
448
|
+
COMPLETED = "completed"
|
|
449
|
+
NOT_PLANNED = "not_planned"
|
|
450
|
+
DUPLICATE = "duplicate"
|
|
451
|
+
REOPENED = "reopened"
|
|
452
|
+
|
|
453
|
+
|
|
394
454
|
class MergeMethod(StrEnum):
|
|
395
455
|
MERGE = "merge"
|
|
396
456
|
SQUASH = "squash"
|
|
397
457
|
REBASE = "rebase"
|
|
398
458
|
|
|
399
459
|
|
|
460
|
+
class Unset:
|
|
461
|
+
"""The type of :data:`UNSET`."""
|
|
462
|
+
|
|
463
|
+
def __repr__(self) -> str:
|
|
464
|
+
return "UNSET"
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
# Sentinel marking an update field the caller did not mention, so that a field
|
|
468
|
+
# left out is distinguishable from one explicitly set to an empty/null value.
|
|
469
|
+
# `None` cannot serve as that marker because GitHub gives it a meaning of its
|
|
470
|
+
# own: `{"milestone": None}` detaches the milestone, while omitting the key
|
|
471
|
+
# leaves it alone. See `Repository.update_issue`.
|
|
472
|
+
UNSET = Unset()
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def _validate_issue_state(state: str, noun: str = "issue") -> None:
|
|
476
|
+
"""Validate a state an issue or milestone can be *set* to.
|
|
477
|
+
|
|
478
|
+
``IssueState.ALL`` is a filter for listing, not a state anything can hold;
|
|
479
|
+
GitHub rejects it with a 422. It is caught here so the mistake is reported
|
|
480
|
+
against the argument rather than as an opaque HTTP error.
|
|
481
|
+
|
|
482
|
+
:param state: The state to set (``open`` or ``closed``).
|
|
483
|
+
:param noun: What is being updated, for the error message.
|
|
484
|
+
:raises ValueError: If the state is anything other than open or closed.
|
|
485
|
+
"""
|
|
486
|
+
if state not in (IssueState.OPEN, IssueState.CLOSED):
|
|
487
|
+
# `str(state)` so a StrEnum renders as 'all' rather than
|
|
488
|
+
# <IssueState.ALL: 'all'>, matching how a plain string argument reads.
|
|
489
|
+
raise ValueError(
|
|
490
|
+
f"Invalid {noun} state {str(state)!r}: a {noun} can only be set to "
|
|
491
|
+
f"'{IssueState.OPEN}' or '{IssueState.CLOSED}'."
|
|
492
|
+
)
|
|
493
|
+
|
|
494
|
+
|
|
400
495
|
class Repository(GitHub):
|
|
401
496
|
"""Abstraction of a GitHub repository."""
|
|
402
497
|
|
|
@@ -414,6 +509,10 @@ class Repository(GitHub):
|
|
|
414
509
|
self._url_branches = f"{self._url_repo}/branches"
|
|
415
510
|
self._url_refs = f"{self._url_repo}/git/refs"
|
|
416
511
|
self._url_issues = f"{self._url_repo}/issues"
|
|
512
|
+
# Issue comments are edited/deleted by comment id, not issue number, so
|
|
513
|
+
# this endpoint is not nested under a specific issue.
|
|
514
|
+
self._url_issue_comments = f"{self._url_issues}/comments"
|
|
515
|
+
self._url_milestones = f"{self._url_repo}/milestones"
|
|
417
516
|
self._url_releases = f"{self._url_repo}/releases"
|
|
418
517
|
self._url_secrets = f"{self._url_repo}/actions/secrets"
|
|
419
518
|
self._url_compare = f"{self._url_repo}/compare"
|
|
@@ -498,6 +597,28 @@ class Repository(GitHub):
|
|
|
498
597
|
"""
|
|
499
598
|
return self._extract_all(url=f"{self._url_pull}/{pr_number}/reviews", n=n)
|
|
500
599
|
|
|
600
|
+
def get_pull_request_review_comments(
|
|
601
|
+
self, pr_number: int, n: int = 0
|
|
602
|
+
) -> list[dict[str, Any]]:
|
|
603
|
+
"""List the review comments on a pull request.
|
|
604
|
+
|
|
605
|
+
These are the line-anchored comments left on the diff, each carrying
|
|
606
|
+
``path``, ``line``/``original_line`` and ``diff_hunk``, and grouped into
|
|
607
|
+
threads via ``in_reply_to_id``. They are a different resource from the
|
|
608
|
+
conversation-level comments returned by ``get_issue_comments``, which the
|
|
609
|
+
pull request shares with its issue; a full picture of a PR's discussion
|
|
610
|
+
needs both.
|
|
611
|
+
|
|
612
|
+
Every review comment is listed, whether it belongs to a submitted review
|
|
613
|
+
or was posted as a standalone comment. Comments still pending in an
|
|
614
|
+
unsubmitted review are not visible to anyone but their author, so they
|
|
615
|
+
are absent.
|
|
616
|
+
|
|
617
|
+
:param pr_number: The number of the pull request.
|
|
618
|
+
:param n: The maximum number of review comments to return (0 means all).
|
|
619
|
+
"""
|
|
620
|
+
return self._extract_all(url=f"{self._url_pull}/{pr_number}/comments", n=n)
|
|
621
|
+
|
|
501
622
|
def get_issues(
|
|
502
623
|
self, state: IssueState = IssueState.OPEN, n: int = 0
|
|
503
624
|
) -> list[dict[str, Any]]:
|
|
@@ -543,7 +664,7 @@ class Repository(GitHub):
|
|
|
543
664
|
|
|
544
665
|
def create_pull_request(
|
|
545
666
|
self,
|
|
546
|
-
json: dict[str,
|
|
667
|
+
json: dict[str, Any],
|
|
547
668
|
model: str = "",
|
|
548
669
|
) -> dict[str, Any] | None:
|
|
549
670
|
"""Create a pull request.
|
|
@@ -591,7 +712,7 @@ class Repository(GitHub):
|
|
|
591
712
|
def merge_pull_request(
|
|
592
713
|
self,
|
|
593
714
|
pr_number: int,
|
|
594
|
-
merge_method: MergeMethod
|
|
715
|
+
merge_method: MergeMethod = MergeMethod.MERGE,
|
|
595
716
|
sha: str = "",
|
|
596
717
|
) -> dict[str, Any]:
|
|
597
718
|
"""Merge a pull request in this repository.
|
|
@@ -655,9 +776,9 @@ class Repository(GitHub):
|
|
|
655
776
|
self,
|
|
656
777
|
pr_number: int,
|
|
657
778
|
*,
|
|
658
|
-
authors: Sequence[str],
|
|
659
|
-
approvers: Sequence[str],
|
|
660
|
-
allowed_types: Sequence[str] = DEFAULT_AUTO_MERGE_TYPES,
|
|
779
|
+
authors: str | Sequence[str],
|
|
780
|
+
approvers: str | Sequence[str],
|
|
781
|
+
allowed_types: str | Sequence[str] = DEFAULT_AUTO_MERGE_TYPES,
|
|
661
782
|
min_age_minutes: int = DEFAULT_MIN_AGE_MINUTES,
|
|
662
783
|
marker: str = DEFAULT_AUTO_MERGE_MARKER,
|
|
663
784
|
) -> str | None:
|
|
@@ -680,9 +801,12 @@ class Repository(GitHub):
|
|
|
680
801
|
the first failure so extra requests are avoided.
|
|
681
802
|
|
|
682
803
|
:param pr_number: The number of the pull request.
|
|
683
|
-
:param authors: Logins whose PRs are eligible for auto-merge
|
|
684
|
-
|
|
685
|
-
:param
|
|
804
|
+
:param authors: Logins whose PRs are eligible for auto-merge, or a
|
|
805
|
+
single login as a bare string.
|
|
806
|
+
:param approvers: Logins whose reviews/marker comments grant approval,
|
|
807
|
+
or a single login as a bare string.
|
|
808
|
+
:param allowed_types: Conventional-Commits title types eligible for
|
|
809
|
+
auto-merge, or a single type as a bare string.
|
|
686
810
|
:param min_age_minutes: The minimum head-commit age in minutes.
|
|
687
811
|
:param marker: The marker substring an approver may comment to approve.
|
|
688
812
|
:return: The validated head SHA when the PR is eligible, else ``None``.
|
|
@@ -690,6 +814,9 @@ class Repository(GitHub):
|
|
|
690
814
|
commit checked here, so a push landing between this gate and the merge
|
|
691
815
|
is rejected rather than silently merged.
|
|
692
816
|
"""
|
|
817
|
+
authors = as_str_sequence(authors)
|
|
818
|
+
approvers = as_str_sequence(approvers)
|
|
819
|
+
allowed_types = as_str_sequence(allowed_types)
|
|
693
820
|
pr = self.get_pull_request(pr_number)
|
|
694
821
|
|
|
695
822
|
def skip(reason: str) -> None:
|
|
@@ -728,20 +855,23 @@ class Repository(GitHub):
|
|
|
728
855
|
def auto_merge_pull_requests(
|
|
729
856
|
self,
|
|
730
857
|
*,
|
|
731
|
-
authors: Sequence[str],
|
|
732
|
-
approvers: Sequence[str],
|
|
733
|
-
allowed_types: Sequence[str] = DEFAULT_AUTO_MERGE_TYPES,
|
|
858
|
+
authors: str | Sequence[str],
|
|
859
|
+
approvers: str | Sequence[str],
|
|
860
|
+
allowed_types: str | Sequence[str] = DEFAULT_AUTO_MERGE_TYPES,
|
|
734
861
|
min_age_minutes: int = DEFAULT_MIN_AGE_MINUTES,
|
|
735
862
|
marker: str = DEFAULT_AUTO_MERGE_MARKER,
|
|
736
|
-
merge_method: MergeMethod
|
|
863
|
+
merge_method: MergeMethod = MergeMethod.MERGE,
|
|
737
864
|
dry_run: bool = False,
|
|
738
865
|
) -> list[int]:
|
|
739
866
|
"""Auto-merge every open pull request that passes ``should_auto_merge``.
|
|
740
867
|
|
|
741
|
-
:param authors: Logins whose PRs are eligible for auto-merge
|
|
742
|
-
allowlist makes nothing
|
|
743
|
-
|
|
744
|
-
:param
|
|
868
|
+
:param authors: Logins whose PRs are eligible for auto-merge, or a
|
|
869
|
+
single login as a bare string. An empty allowlist makes nothing
|
|
870
|
+
eligible (fail-safe).
|
|
871
|
+
:param approvers: Logins whose reviews/marker comments grant approval,
|
|
872
|
+
or a single login as a bare string.
|
|
873
|
+
:param allowed_types: Conventional-Commits title types eligible for
|
|
874
|
+
auto-merge, or a single type as a bare string.
|
|
745
875
|
:param min_age_minutes: The minimum head-commit age in minutes.
|
|
746
876
|
:param marker: The marker substring an approver may comment to approve.
|
|
747
877
|
:param merge_method: The merge method to use (``merge``, ``squash`` or
|
|
@@ -751,6 +881,9 @@ class Repository(GitHub):
|
|
|
751
881
|
:return: The numbers of the PRs that were merged (or, under ``dry_run``,
|
|
752
882
|
that would have been merged).
|
|
753
883
|
"""
|
|
884
|
+
authors = as_str_sequence(authors)
|
|
885
|
+
approvers = as_str_sequence(approvers)
|
|
886
|
+
allowed_types = as_str_sequence(allowed_types)
|
|
754
887
|
eligible = []
|
|
755
888
|
for pr in self.get_pull_requests():
|
|
756
889
|
number = pr["number"]
|
|
@@ -930,6 +1063,389 @@ class Repository(GitHub):
|
|
|
930
1063
|
"""
|
|
931
1064
|
return self.pr_has_change(pr_number=pr_number, pred=pred)
|
|
932
1065
|
|
|
1066
|
+
def create_issue(
|
|
1067
|
+
self,
|
|
1068
|
+
title: str,
|
|
1069
|
+
body: str = "",
|
|
1070
|
+
labels: str | Sequence[str] = (),
|
|
1071
|
+
assignees: str | Sequence[str] = (),
|
|
1072
|
+
milestone: int | None = None,
|
|
1073
|
+
) -> dict[str, Any]:
|
|
1074
|
+
"""Create an issue in this repository.
|
|
1075
|
+
|
|
1076
|
+
:param title: The title of the new issue.
|
|
1077
|
+
:param body: The Markdown body of the new issue.
|
|
1078
|
+
:param labels: Names of labels to attach to the new issue, or a single
|
|
1079
|
+
name as a bare string. Silently dropped unless the authenticated
|
|
1080
|
+
user has push access to the repository. Labels that do not already
|
|
1081
|
+
exist are created by GitHub.
|
|
1082
|
+
:param assignees: Logins of users to assign to the new issue, or a
|
|
1083
|
+
single login as a bare string. Silently dropped unless the
|
|
1084
|
+
authenticated user has push access to the repository.
|
|
1085
|
+
:param milestone: The number of the milestone to associate the new issue
|
|
1086
|
+
with (see ``get_milestones``). Silently dropped unless the
|
|
1087
|
+
authenticated user has push access to the repository.
|
|
1088
|
+
"""
|
|
1089
|
+
labels = as_str_sequence(labels)
|
|
1090
|
+
assignees = as_str_sequence(assignees)
|
|
1091
|
+
json: dict[str, Any] = {"title": title}
|
|
1092
|
+
if body:
|
|
1093
|
+
json["body"] = body
|
|
1094
|
+
if labels:
|
|
1095
|
+
json["labels"] = list(labels)
|
|
1096
|
+
if assignees:
|
|
1097
|
+
json["assignees"] = list(assignees)
|
|
1098
|
+
if milestone is not None:
|
|
1099
|
+
json["milestone"] = milestone
|
|
1100
|
+
return self._post(url=self._url_issues, json=json).json()
|
|
1101
|
+
|
|
1102
|
+
def get_issue(self, issue_number: int) -> dict[str, Any]:
|
|
1103
|
+
"""Get a single issue in this repository.
|
|
1104
|
+
|
|
1105
|
+
:param issue_number: The number of the issue. A pull request number is
|
|
1106
|
+
also accepted, since GitHub models pull requests as issues; the
|
|
1107
|
+
payload then carries a ``pull_request`` key.
|
|
1108
|
+
"""
|
|
1109
|
+
return self._get(url=f"{self._url_issues}/{issue_number}").json()
|
|
1110
|
+
|
|
1111
|
+
def update_issue(
|
|
1112
|
+
self,
|
|
1113
|
+
issue_number: int,
|
|
1114
|
+
*,
|
|
1115
|
+
title: str | Unset = UNSET,
|
|
1116
|
+
body: str | Unset = UNSET,
|
|
1117
|
+
state: IssueState | Unset = UNSET,
|
|
1118
|
+
state_reason: IssueStateReason | None | Unset = UNSET,
|
|
1119
|
+
duplicate_issue_id: int | Unset = UNSET,
|
|
1120
|
+
labels: str | Sequence[str] | Unset = UNSET,
|
|
1121
|
+
assignees: str | Sequence[str] | Unset = UNSET,
|
|
1122
|
+
milestone: int | None | Unset = UNSET,
|
|
1123
|
+
) -> dict[str, Any]:
|
|
1124
|
+
"""Update an issue in this repository.
|
|
1125
|
+
|
|
1126
|
+
Only the fields passed are sent, so an update never disturbs a field the
|
|
1127
|
+
caller did not mention. That distinction is what the ``UNSET`` default
|
|
1128
|
+
buys: ``None`` and the empty sequence are meaningful values here (they
|
|
1129
|
+
detach the milestone and clear the labels/assignees respectively), so
|
|
1130
|
+
they cannot double as "leave this alone".
|
|
1131
|
+
|
|
1132
|
+
``labels`` and ``assignees`` replace the existing set rather than adding
|
|
1133
|
+
to it; to add or remove a few entries without knowing the rest, use
|
|
1134
|
+
``add_issue_labels``/``remove_issue_label`` or
|
|
1135
|
+
``add_issue_assignees``/``remove_issue_assignees``.
|
|
1136
|
+
|
|
1137
|
+
:param issue_number: The number of the issue.
|
|
1138
|
+
:param title: A new title.
|
|
1139
|
+
:param body: A new Markdown body; pass ``""`` to empty it.
|
|
1140
|
+
:param state: ``open`` or ``closed`` (see also ``close_issue``).
|
|
1141
|
+
:param state_reason: Why the issue is in that state (``completed``,
|
|
1142
|
+
``not_planned``, ``duplicate`` or ``reopened``), or None to clear it.
|
|
1143
|
+
It must be passed alongside ``state``, and GitHub applies it only
|
|
1144
|
+
when the state actually changes: re-closing an already-closed issue
|
|
1145
|
+
keeps the reason it was closed with.
|
|
1146
|
+
:param duplicate_issue_id: The id (not the number) of the issue this one
|
|
1147
|
+
duplicates. GitHub requires it when ``state_reason`` is ``duplicate``
|
|
1148
|
+
and ignores it otherwise, so the two must be passed together.
|
|
1149
|
+
:param labels: Names of the labels the issue should carry, replacing any
|
|
1150
|
+
current ones; a bare string is one label, and an empty sequence
|
|
1151
|
+
clears them all. Labels that do not already exist are created.
|
|
1152
|
+
:param assignees: Logins the issue should be assigned to, replacing any
|
|
1153
|
+
current ones; a bare string is one login, and an empty sequence
|
|
1154
|
+
unassigns everyone.
|
|
1155
|
+
:param milestone: The number of the milestone to associate the issue
|
|
1156
|
+
with; pass None to detach it.
|
|
1157
|
+
:raises ValueError: If no field is given (the request would be a no-op),
|
|
1158
|
+
if ``state`` is neither open nor closed, if ``state_reason`` is given
|
|
1159
|
+
without ``state``, or if ``state_reason='duplicate'`` and
|
|
1160
|
+
``duplicate_issue_id`` are not given together.
|
|
1161
|
+
"""
|
|
1162
|
+
# GitHub documents state_reason as "ignored unless state is changed", so
|
|
1163
|
+
# sending it alone silently does nothing. Refuse it rather than let the
|
|
1164
|
+
# caller believe an update happened.
|
|
1165
|
+
if isinstance(state, Unset) and not isinstance(state_reason, Unset):
|
|
1166
|
+
raise ValueError(
|
|
1167
|
+
"state_reason is ignored by GitHub unless state changes too; "
|
|
1168
|
+
"pass state (e.g. IssueState.CLOSED) alongside it."
|
|
1169
|
+
)
|
|
1170
|
+
# `duplicate` and `duplicate_issue_id` are only meaningful together:
|
|
1171
|
+
# without the id GitHub rejects the request, and without the reason it
|
|
1172
|
+
# drops the id. Comparison is by value so an equivalent plain string
|
|
1173
|
+
# still takes this path, as it does everywhere else a state is checked.
|
|
1174
|
+
is_duplicate = (
|
|
1175
|
+
not isinstance(state_reason, Unset)
|
|
1176
|
+
and state_reason is not None
|
|
1177
|
+
and str(state_reason) == IssueStateReason.DUPLICATE
|
|
1178
|
+
)
|
|
1179
|
+
if is_duplicate and isinstance(duplicate_issue_id, Unset):
|
|
1180
|
+
raise ValueError(
|
|
1181
|
+
"state_reason='duplicate' requires duplicate_issue_id, the id "
|
|
1182
|
+
"of the issue this one duplicates."
|
|
1183
|
+
)
|
|
1184
|
+
if not is_duplicate and not isinstance(duplicate_issue_id, Unset):
|
|
1185
|
+
raise ValueError(
|
|
1186
|
+
"duplicate_issue_id is ignored by GitHub unless state_reason is "
|
|
1187
|
+
"'duplicate'; pass IssueStateReason.DUPLICATE alongside it."
|
|
1188
|
+
)
|
|
1189
|
+
json: dict[str, Any] = {}
|
|
1190
|
+
if not isinstance(title, Unset):
|
|
1191
|
+
json["title"] = title
|
|
1192
|
+
if not isinstance(body, Unset):
|
|
1193
|
+
json["body"] = body
|
|
1194
|
+
if not isinstance(state, Unset):
|
|
1195
|
+
_validate_issue_state(state)
|
|
1196
|
+
json["state"] = str(state)
|
|
1197
|
+
if not isinstance(state_reason, Unset):
|
|
1198
|
+
json["state_reason"] = None if state_reason is None else str(state_reason)
|
|
1199
|
+
if not isinstance(duplicate_issue_id, Unset):
|
|
1200
|
+
json["duplicate_issue_id"] = duplicate_issue_id
|
|
1201
|
+
if not isinstance(labels, Unset):
|
|
1202
|
+
json["labels"] = list(as_str_sequence(labels))
|
|
1203
|
+
if not isinstance(assignees, Unset):
|
|
1204
|
+
json["assignees"] = list(as_str_sequence(assignees))
|
|
1205
|
+
if not isinstance(milestone, Unset):
|
|
1206
|
+
json["milestone"] = milestone
|
|
1207
|
+
if not json:
|
|
1208
|
+
raise ValueError(
|
|
1209
|
+
f"No field to update was given for issue #{issue_number}; "
|
|
1210
|
+
"pass at least one of title, body, state, state_reason, "
|
|
1211
|
+
"labels, assignees or milestone."
|
|
1212
|
+
)
|
|
1213
|
+
return self._patch(url=f"{self._url_issues}/{issue_number}", json=json).json()
|
|
1214
|
+
|
|
1215
|
+
def close_issue(
|
|
1216
|
+
self,
|
|
1217
|
+
issue_number: int,
|
|
1218
|
+
state_reason: IssueStateReason = IssueStateReason.COMPLETED,
|
|
1219
|
+
duplicate_issue_id: int | Unset = UNSET,
|
|
1220
|
+
) -> dict[str, Any]:
|
|
1221
|
+
"""Close an issue in this repository.
|
|
1222
|
+
|
|
1223
|
+
Closing an already-closed issue is not an error, but it is not a state
|
|
1224
|
+
change either, so GitHub keeps the existing ``state_reason``. Reopen the
|
|
1225
|
+
issue first to re-close it with a different reason.
|
|
1226
|
+
|
|
1227
|
+
:param issue_number: The number of the issue.
|
|
1228
|
+
:param state_reason: Why the issue is being closed (``completed``, the
|
|
1229
|
+
default, ``not_planned`` or ``duplicate``).
|
|
1230
|
+
:param duplicate_issue_id: The id (not the number) of the issue this one
|
|
1231
|
+
duplicates. Required when ``state_reason`` is ``duplicate``, and
|
|
1232
|
+
rejected otherwise (see ``update_issue``).
|
|
1233
|
+
"""
|
|
1234
|
+
return self.update_issue(
|
|
1235
|
+
issue_number,
|
|
1236
|
+
state=IssueState.CLOSED,
|
|
1237
|
+
state_reason=state_reason,
|
|
1238
|
+
duplicate_issue_id=duplicate_issue_id,
|
|
1239
|
+
)
|
|
1240
|
+
|
|
1241
|
+
def reopen_issue(self, issue_number: int) -> dict[str, Any]:
|
|
1242
|
+
"""Reopen a closed issue in this repository.
|
|
1243
|
+
|
|
1244
|
+
:param issue_number: The number of the issue.
|
|
1245
|
+
"""
|
|
1246
|
+
return self.update_issue(
|
|
1247
|
+
issue_number, state=IssueState.OPEN, state_reason=IssueStateReason.REOPENED
|
|
1248
|
+
)
|
|
1249
|
+
|
|
1250
|
+
def update_issue_comment(self, comment_id: int, body: str) -> dict[str, Any]:
|
|
1251
|
+
"""Edit an existing comment on an issue or pull request.
|
|
1252
|
+
|
|
1253
|
+
:param comment_id: The id of the comment (its ``id`` field, which is
|
|
1254
|
+
unique repository-wide and unrelated to the issue number).
|
|
1255
|
+
:param body: The new Markdown body, replacing the current one.
|
|
1256
|
+
"""
|
|
1257
|
+
return self._patch(
|
|
1258
|
+
url=f"{self._url_issue_comments}/{comment_id}",
|
|
1259
|
+
json={"body": body},
|
|
1260
|
+
).json()
|
|
1261
|
+
|
|
1262
|
+
def delete_issue_comment(self, comment_id: int) -> requests.Response:
|
|
1263
|
+
"""Delete a comment from an issue or pull request.
|
|
1264
|
+
|
|
1265
|
+
:param comment_id: The id of the comment (its ``id`` field, which is
|
|
1266
|
+
unique repository-wide and unrelated to the issue number).
|
|
1267
|
+
"""
|
|
1268
|
+
return self._delete(url=f"{self._url_issue_comments}/{comment_id}")
|
|
1269
|
+
|
|
1270
|
+
def get_issue_labels(self, issue_number: int, n: int = 0) -> list[dict[str, Any]]:
|
|
1271
|
+
"""List the labels on an issue.
|
|
1272
|
+
|
|
1273
|
+
:param issue_number: The number of the issue.
|
|
1274
|
+
:param n: The maximum number of labels to return (0 means all).
|
|
1275
|
+
"""
|
|
1276
|
+
return self._extract_all(url=f"{self._url_issues}/{issue_number}/labels", n=n)
|
|
1277
|
+
|
|
1278
|
+
def add_issue_labels(
|
|
1279
|
+
self, issue_number: int, labels: str | Sequence[str]
|
|
1280
|
+
) -> list[dict[str, Any]]:
|
|
1281
|
+
"""Add labels to an issue, keeping the ones already on it.
|
|
1282
|
+
|
|
1283
|
+
:param issue_number: The number of the issue.
|
|
1284
|
+
:param labels: Names of the labels to add, or a single name as a bare
|
|
1285
|
+
string. Labels that do not already exist are created by GitHub.
|
|
1286
|
+
:return: All labels on the issue after the addition.
|
|
1287
|
+
:raises ValueError: If no label is given. GitHub rejects an empty list
|
|
1288
|
+
here with a 422; use ``set_issue_labels`` to clear labels instead.
|
|
1289
|
+
"""
|
|
1290
|
+
names = list(as_str_sequence(labels))
|
|
1291
|
+
if not names:
|
|
1292
|
+
raise ValueError(
|
|
1293
|
+
f"No label to add was given for issue #{issue_number}; "
|
|
1294
|
+
"pass at least one name, or use set_issue_labels(..., ()) to "
|
|
1295
|
+
"remove every label."
|
|
1296
|
+
)
|
|
1297
|
+
return self._post(
|
|
1298
|
+
url=f"{self._url_issues}/{issue_number}/labels",
|
|
1299
|
+
json={"labels": names},
|
|
1300
|
+
).json()
|
|
1301
|
+
|
|
1302
|
+
def set_issue_labels(
|
|
1303
|
+
self, issue_number: int, labels: str | Sequence[str]
|
|
1304
|
+
) -> list[dict[str, Any]]:
|
|
1305
|
+
"""Replace all labels on an issue with the given ones.
|
|
1306
|
+
|
|
1307
|
+
:param issue_number: The number of the issue.
|
|
1308
|
+
:param labels: Names of the labels the issue should carry, or a single
|
|
1309
|
+
name as a bare string; an empty sequence removes every label.
|
|
1310
|
+
:return: All labels on the issue after the replacement.
|
|
1311
|
+
"""
|
|
1312
|
+
return self._put(
|
|
1313
|
+
url=f"{self._url_issues}/{issue_number}/labels",
|
|
1314
|
+
json={"labels": list(as_str_sequence(labels))},
|
|
1315
|
+
).json()
|
|
1316
|
+
|
|
1317
|
+
def remove_issue_label(self, issue_number: int, label: str) -> list[dict[str, Any]]:
|
|
1318
|
+
"""Remove a single label from an issue.
|
|
1319
|
+
|
|
1320
|
+
:param issue_number: The number of the issue.
|
|
1321
|
+
:param label: The name of the label to remove. GitHub responds with 404
|
|
1322
|
+
if the issue does not carry it.
|
|
1323
|
+
:return: The labels remaining on the issue.
|
|
1324
|
+
"""
|
|
1325
|
+
# A label name may contain spaces and slashes (e.g. "good first issue"),
|
|
1326
|
+
# so it is URL-encoded rather than interpolated raw into the path.
|
|
1327
|
+
return self._delete(
|
|
1328
|
+
url=f"{self._url_issues}/{issue_number}/labels/{quote(label, safe='')}",
|
|
1329
|
+
).json()
|
|
1330
|
+
|
|
1331
|
+
def add_issue_assignees(
|
|
1332
|
+
self, issue_number: int, assignees: str | Sequence[str]
|
|
1333
|
+
) -> dict[str, Any]:
|
|
1334
|
+
"""Assign users to an issue, keeping those already assigned.
|
|
1335
|
+
|
|
1336
|
+
:param issue_number: The number of the issue.
|
|
1337
|
+
:param assignees: Logins to assign, or a single login as a bare string.
|
|
1338
|
+
A login without push access to the repository is silently ignored.
|
|
1339
|
+
:return: The updated issue.
|
|
1340
|
+
"""
|
|
1341
|
+
return self._post(
|
|
1342
|
+
url=f"{self._url_issues}/{issue_number}/assignees",
|
|
1343
|
+
json={"assignees": list(as_str_sequence(assignees))},
|
|
1344
|
+
).json()
|
|
1345
|
+
|
|
1346
|
+
def remove_issue_assignees(
|
|
1347
|
+
self, issue_number: int, assignees: str | Sequence[str]
|
|
1348
|
+
) -> dict[str, Any]:
|
|
1349
|
+
"""Unassign users from an issue, leaving the other assignees in place.
|
|
1350
|
+
|
|
1351
|
+
:param issue_number: The number of the issue.
|
|
1352
|
+
:param assignees: Logins to unassign, or a single login as a bare string.
|
|
1353
|
+
:return: The updated issue.
|
|
1354
|
+
"""
|
|
1355
|
+
return self._delete(
|
|
1356
|
+
url=f"{self._url_issues}/{issue_number}/assignees",
|
|
1357
|
+
json={"assignees": list(as_str_sequence(assignees))},
|
|
1358
|
+
).json()
|
|
1359
|
+
|
|
1360
|
+
def get_milestones(
|
|
1361
|
+
self, state: IssueState = IssueState.OPEN, n: int = 0
|
|
1362
|
+
) -> list[dict[str, Any]]:
|
|
1363
|
+
"""List milestones in this repository.
|
|
1364
|
+
|
|
1365
|
+
:param state: Filter milestones by state: ``open`` (the default),
|
|
1366
|
+
``closed``, or ``all``.
|
|
1367
|
+
:param n: The maximum number of milestones to return (0 means all).
|
|
1368
|
+
"""
|
|
1369
|
+
return self._extract_all(url=self._url_milestones, params={"state": state}, n=n)
|
|
1370
|
+
|
|
1371
|
+
def create_milestone(
|
|
1372
|
+
self,
|
|
1373
|
+
title: str,
|
|
1374
|
+
description: str = "",
|
|
1375
|
+
due_on: str = "",
|
|
1376
|
+
) -> dict[str, Any]:
|
|
1377
|
+
"""Create a milestone in this repository.
|
|
1378
|
+
|
|
1379
|
+
:param title: The title of the milestone.
|
|
1380
|
+
:param description: A description of the milestone.
|
|
1381
|
+
:param due_on: When the milestone is due, as an ISO-8601 timestamp
|
|
1382
|
+
(e.g. ``2026-12-31T00:00:00Z``).
|
|
1383
|
+
:return: The new milestone, whose ``number`` is what ``create_issue``
|
|
1384
|
+
and ``update_issue`` take as their ``milestone``.
|
|
1385
|
+
"""
|
|
1386
|
+
json: dict[str, Any] = {"title": title}
|
|
1387
|
+
if description:
|
|
1388
|
+
json["description"] = description
|
|
1389
|
+
if due_on:
|
|
1390
|
+
json["due_on"] = due_on
|
|
1391
|
+
return self._post(url=self._url_milestones, json=json).json()
|
|
1392
|
+
|
|
1393
|
+
def update_milestone(
|
|
1394
|
+
self,
|
|
1395
|
+
milestone_number: int,
|
|
1396
|
+
*,
|
|
1397
|
+
title: str | Unset = UNSET,
|
|
1398
|
+
description: str | Unset = UNSET,
|
|
1399
|
+
state: IssueState | Unset = UNSET,
|
|
1400
|
+
due_on: str | Unset = UNSET,
|
|
1401
|
+
) -> dict[str, Any]:
|
|
1402
|
+
"""Update a milestone in this repository.
|
|
1403
|
+
|
|
1404
|
+
As in ``update_issue``, only the fields passed are sent.
|
|
1405
|
+
|
|
1406
|
+
:param milestone_number: The ``number`` of the milestone (not its id).
|
|
1407
|
+
:param title: A new title.
|
|
1408
|
+
:param description: A new description; pass ``""`` to empty it.
|
|
1409
|
+
:param state: ``open`` or ``closed``.
|
|
1410
|
+
:param due_on: A new due date, as an ISO-8601 timestamp. GitHub's
|
|
1411
|
+
milestone endpoint takes no null due date, so an existing one can be
|
|
1412
|
+
moved but not removed.
|
|
1413
|
+
:raises ValueError: If no field is given, if ``state`` is neither open
|
|
1414
|
+
nor closed, or if ``due_on`` is empty.
|
|
1415
|
+
"""
|
|
1416
|
+
json: dict[str, Any] = {}
|
|
1417
|
+
if not isinstance(title, Unset):
|
|
1418
|
+
json["title"] = title
|
|
1419
|
+
if not isinstance(description, Unset):
|
|
1420
|
+
json["description"] = description
|
|
1421
|
+
if not isinstance(state, Unset):
|
|
1422
|
+
_validate_issue_state(state, noun="milestone")
|
|
1423
|
+
json["state"] = str(state)
|
|
1424
|
+
if not isinstance(due_on, Unset):
|
|
1425
|
+
if not due_on:
|
|
1426
|
+
raise ValueError(
|
|
1427
|
+
"due_on must be a non-empty ISO-8601 timestamp; GitHub "
|
|
1428
|
+
"rejects an empty one and offers no way to clear a due date."
|
|
1429
|
+
)
|
|
1430
|
+
json["due_on"] = due_on
|
|
1431
|
+
if not json:
|
|
1432
|
+
raise ValueError(
|
|
1433
|
+
f"No field to update was given for milestone #{milestone_number}; "
|
|
1434
|
+
"pass at least one of title, description, state or due_on."
|
|
1435
|
+
)
|
|
1436
|
+
return self._patch(
|
|
1437
|
+
url=f"{self._url_milestones}/{milestone_number}", json=json
|
|
1438
|
+
).json()
|
|
1439
|
+
|
|
1440
|
+
def delete_milestone(self, milestone_number: int) -> requests.Response:
|
|
1441
|
+
"""Delete a milestone from this repository.
|
|
1442
|
+
|
|
1443
|
+
Issues associated with the milestone are not deleted; they simply lose it.
|
|
1444
|
+
|
|
1445
|
+
:param milestone_number: The ``number`` of the milestone (not its id).
|
|
1446
|
+
"""
|
|
1447
|
+
return self._delete(url=f"{self._url_milestones}/{milestone_number}")
|
|
1448
|
+
|
|
933
1449
|
def get_issue_comments(self, issue_number: int, n: int = 0) -> list[dict[str, Any]]:
|
|
934
1450
|
"""List comments on an issue in this repository.
|
|
935
1451
|
|
|
@@ -947,7 +1463,6 @@ class Repository(GitHub):
|
|
|
947
1463
|
return self._post(
|
|
948
1464
|
url=f"{self._url_issues}/{issue_number}/comments",
|
|
949
1465
|
json={"body": body},
|
|
950
|
-
timeout=10,
|
|
951
1466
|
).json()
|
|
952
1467
|
|
|
953
1468
|
def archive(self) -> requests.Response:
|
|
@@ -14,6 +14,8 @@ from dulwich.refs import Ref
|
|
|
14
14
|
from dulwich.repo import Repo
|
|
15
15
|
from tenacity import retry, stop_after_attempt, wait_exponential
|
|
16
16
|
|
|
17
|
+
from github_rest_api.utils import as_str_sequence
|
|
18
|
+
|
|
17
19
|
|
|
18
20
|
def _get_commit(name: bytes) -> bytes:
|
|
19
21
|
"""Resolve a commit SHA or branch name to a commit SHA string."""
|
|
@@ -69,6 +71,8 @@ def has_relevant_changes(
|
|
|
69
71
|
) -> bool:
|
|
70
72
|
if not commit1 or not commit2:
|
|
71
73
|
return True
|
|
74
|
+
image_dirs = as_str_sequence(image_dirs)
|
|
75
|
+
paths_monitoring = as_str_sequence(paths_monitoring)
|
|
72
76
|
if isinstance(commit1, str):
|
|
73
77
|
commit1 = commit1.encode()
|
|
74
78
|
if isinstance(commit2, str):
|
|
@@ -149,6 +153,8 @@ def build_images(
|
|
|
149
153
|
tool: str = "podman",
|
|
150
154
|
registry: str = "quay.io/legendu",
|
|
151
155
|
):
|
|
156
|
+
image_dirs = as_str_sequence(image_dirs)
|
|
157
|
+
paths_monitoring = as_str_sequence(paths_monitoring)
|
|
152
158
|
_validate_paths_exist(image_dirs, "image dirs")
|
|
153
159
|
_validate_paths_exist(paths_monitoring, "monitored paths")
|
|
154
160
|
if not has_relevant_changes(commit1, commit2, image_dirs, paths_monitoring):
|
|
@@ -70,8 +70,9 @@ def parse_args(args=None, namespace=None) -> Namespace:
|
|
|
70
70
|
parser.add_argument(
|
|
71
71
|
"--merge-method",
|
|
72
72
|
dest="merge_method",
|
|
73
|
-
|
|
74
|
-
|
|
73
|
+
type=MergeMethod,
|
|
74
|
+
choices=list(MergeMethod),
|
|
75
|
+
default=MergeMethod.MERGE,
|
|
75
76
|
help="The merge method to use.",
|
|
76
77
|
)
|
|
77
78
|
parser.add_argument(
|
|
@@ -10,8 +10,10 @@ from collections.abc import Sequence
|
|
|
10
10
|
from pathlib import Path
|
|
11
11
|
|
|
12
12
|
from dulwich import porcelain
|
|
13
|
+
from dulwich.refs import HEADREF, LOCAL_BRANCH_PREFIX, Ref
|
|
13
14
|
|
|
14
15
|
from github_rest_api import Organization, User
|
|
16
|
+
from github_rest_api.utils import as_str_sequence
|
|
15
17
|
|
|
16
18
|
logger = logging.getLogger(__name__)
|
|
17
19
|
|
|
@@ -55,20 +57,39 @@ def _active_branch(path: Path) -> str:
|
|
|
55
57
|
return porcelain.active_branch(path).decode()
|
|
56
58
|
except (IndexError, ValueError) as e:
|
|
57
59
|
raise ValueError(
|
|
58
|
-
f"
|
|
59
|
-
"
|
|
60
|
+
f"Cannot determine the initial branch of the local repo at '{path}' "
|
|
61
|
+
"as HEAD does not point at a branch. "
|
|
60
62
|
"Check out a branch (e.g. `git switch -c main`) before running this command."
|
|
61
63
|
) from e
|
|
62
64
|
|
|
63
65
|
|
|
66
|
+
def _head_and_branches(path: Path) -> tuple[str | None, set[str]]:
|
|
67
|
+
"""Resolve HEAD and the local branches with a single open of the repo.
|
|
68
|
+
|
|
69
|
+
Returns the commit SHA HEAD points at (None if it does not resolve to one)
|
|
70
|
+
and the existing local branch names (without the `refs/heads/` prefix).
|
|
71
|
+
"""
|
|
72
|
+
with porcelain.open_repo_closing(path) as repo:
|
|
73
|
+
try:
|
|
74
|
+
head = repo.refs[HEADREF].decode()
|
|
75
|
+
except KeyError:
|
|
76
|
+
head = None
|
|
77
|
+
branches = {b.decode() for b in repo.refs.keys(base=Ref(LOCAL_BRANCH_PREFIX))}
|
|
78
|
+
return head, branches
|
|
79
|
+
|
|
80
|
+
|
|
64
81
|
def _init_local_repo(
|
|
65
82
|
repo: str,
|
|
66
83
|
language: str,
|
|
67
84
|
dir_: str,
|
|
68
85
|
token: str,
|
|
69
86
|
protocol: str,
|
|
87
|
+
push: bool,
|
|
70
88
|
branches: Sequence[str] = ("main",),
|
|
71
89
|
) -> None:
|
|
90
|
+
branches = list(dict.fromkeys(branches))
|
|
91
|
+
if not branches:
|
|
92
|
+
raise ValueError("At least one branch must be specified.")
|
|
72
93
|
repo_name = repo.split("/")[-1]
|
|
73
94
|
path = Path(dir_) if dir_ else Path(repo_name)
|
|
74
95
|
path.mkdir(parents=True, exist_ok=True)
|
|
@@ -79,7 +100,9 @@ def _init_local_repo(
|
|
|
79
100
|
if not (path / ".git").exists():
|
|
80
101
|
porcelain.init(path=path)
|
|
81
102
|
logger.info("Initialized empty Git repository in %s", path)
|
|
82
|
-
|
|
103
|
+
head, existing_branches = _head_and_branches(path)
|
|
104
|
+
if head is None and not existing_branches:
|
|
105
|
+
# A brand new repository: no commit and no branch yet.
|
|
83
106
|
initial_branch = _active_branch(path)
|
|
84
107
|
porcelain.add(repo=path, paths=["README.md"])
|
|
85
108
|
logger.info("Added README.md to staging")
|
|
@@ -94,21 +117,43 @@ def _init_local_repo(
|
|
|
94
117
|
if initial_branch not in branches:
|
|
95
118
|
porcelain.branch_delete(repo=path, name=initial_branch)
|
|
96
119
|
logger.info("Deleted initial branch '%s'", initial_branch)
|
|
120
|
+
else:
|
|
121
|
+
missing = [b for b in branches if b not in existing_branches]
|
|
122
|
+
if missing and head is None:
|
|
123
|
+
# HEAD is unborn (e.g. after `git switch --orphan`) but branches
|
|
124
|
+
# already exist, so there is no commit to branch from. Without this,
|
|
125
|
+
# dulwich falls back to its "HEAD" objectish default and fails deep
|
|
126
|
+
# inside the object store with "Invalid object name b'HEAD'".
|
|
127
|
+
# `existing_branches` is never empty here: an empty one with a None
|
|
128
|
+
# head takes the brand-new-repository path above.
|
|
129
|
+
raise ValueError(
|
|
130
|
+
"Cannot create the branch(es) "
|
|
131
|
+
f"{', '.join(repr(b) for b in missing)} in the local repo at "
|
|
132
|
+
f"'{path}' as HEAD does not point at a commit to branch from. "
|
|
133
|
+
f"Check out an existing branch (e.g. "
|
|
134
|
+
f"`git switch {sorted(existing_branches)[0]}`) "
|
|
135
|
+
"before running this command."
|
|
136
|
+
)
|
|
137
|
+
# Point new branches at HEAD's commit explicitly. Dulwich fails to resolve
|
|
138
|
+
# its default "HEAD" objectish when HEAD is detached, which is the normal
|
|
139
|
+
# state of a colocated Jujutsu repository.
|
|
140
|
+
for branch in missing:
|
|
141
|
+
porcelain.branch_create(repo=path, name=branch, objectish=head)
|
|
142
|
+
logger.info("Created branch '%s' from HEAD", branch)
|
|
97
143
|
_ensure_remote(path, repo, protocol)
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
)
|
|
111
|
-
logger.info("Successfully pushed branch '%s'.", branch)
|
|
144
|
+
if push:
|
|
145
|
+
for branch in branches:
|
|
146
|
+
logger.info("Pushing branch '%s' to remote '%s'...", branch, repo)
|
|
147
|
+
porcelain.push(
|
|
148
|
+
repo=path,
|
|
149
|
+
remote_location=_remote_url(repo, "https"),
|
|
150
|
+
refspecs=[branch.encode()],
|
|
151
|
+
username="x-access-token",
|
|
152
|
+
password=token,
|
|
153
|
+
)
|
|
154
|
+
logger.info("Successfully pushed branch '%s'.", branch)
|
|
155
|
+
else:
|
|
156
|
+
logger.info("Skipping push (pass --push to push branches to the remote).")
|
|
112
157
|
_add_workflow(path, language)
|
|
113
158
|
|
|
114
159
|
|
|
@@ -120,8 +165,10 @@ def create_github_repo(
|
|
|
120
165
|
dir_: str,
|
|
121
166
|
token: str,
|
|
122
167
|
protocol: str,
|
|
168
|
+
push: bool,
|
|
123
169
|
branches: Sequence[str] = ("main",),
|
|
124
170
|
) -> None:
|
|
171
|
+
branches = as_str_sequence(branches)
|
|
125
172
|
token = token or os.getenv("GITHUB_TOKEN", "")
|
|
126
173
|
if not token:
|
|
127
174
|
token = getpass.getpass("Please enter your GitHub token: ")
|
|
@@ -141,6 +188,7 @@ def create_github_repo(
|
|
|
141
188
|
token=token,
|
|
142
189
|
branches=branches,
|
|
143
190
|
protocol=protocol,
|
|
191
|
+
push=push,
|
|
144
192
|
)
|
|
145
193
|
|
|
146
194
|
|
|
@@ -223,7 +271,7 @@ def parse_args(args=None, namespace=None):
|
|
|
223
271
|
nargs="+",
|
|
224
272
|
default=["main"],
|
|
225
273
|
metavar="BRANCH",
|
|
226
|
-
help="Branches to create and push
|
|
274
|
+
help="Branches to create (and push if --push is set) (default: main).",
|
|
227
275
|
)
|
|
228
276
|
parser.add_argument(
|
|
229
277
|
"--https",
|
|
@@ -233,6 +281,12 @@ def parse_args(args=None, namespace=None):
|
|
|
233
281
|
default="git",
|
|
234
282
|
help="Use the HTTPS protocol for the remote URL.",
|
|
235
283
|
)
|
|
284
|
+
parser.add_argument(
|
|
285
|
+
"--push",
|
|
286
|
+
dest="push",
|
|
287
|
+
action="store_true",
|
|
288
|
+
help="Push branches to the remote (by default, nothing is pushed).",
|
|
289
|
+
)
|
|
236
290
|
parser.add_argument(
|
|
237
291
|
"-v",
|
|
238
292
|
"--verbose",
|
|
@@ -256,6 +310,7 @@ def main() -> int:
|
|
|
256
310
|
token=args.token,
|
|
257
311
|
branches=args.branches,
|
|
258
312
|
protocol=args.protocol,
|
|
313
|
+
push=args.push,
|
|
259
314
|
)
|
|
260
315
|
except Exception as e:
|
|
261
316
|
logger.error("Failed to create GitHub repo: %s", e, exc_info=args.verbose)
|
|
@@ -5,6 +5,7 @@ The branch is updated (using dev) before creating the PR.
|
|
|
5
5
|
import os
|
|
6
6
|
import sys
|
|
7
7
|
from argparse import ArgumentParser, Namespace
|
|
8
|
+
from typing import Any
|
|
8
9
|
|
|
9
10
|
from github_rest_api import Repository
|
|
10
11
|
from github_rest_api.github import DEFAULT_PR_MODEL
|
|
@@ -64,6 +65,12 @@ def parse_args(args=None, namespace=None) -> Namespace:
|
|
|
64
65
|
action="store_true",
|
|
65
66
|
help="Update the head branch using the base branch before creating the pull request.",
|
|
66
67
|
)
|
|
68
|
+
parser.add_argument(
|
|
69
|
+
"--draft",
|
|
70
|
+
dest="draft",
|
|
71
|
+
action="store_true",
|
|
72
|
+
help="Create the pull request as a draft.",
|
|
73
|
+
)
|
|
67
74
|
parser.add_argument(
|
|
68
75
|
"--model",
|
|
69
76
|
dest="model",
|
|
@@ -100,7 +107,7 @@ def main() -> int:
|
|
|
100
107
|
repo = Repository(args.token, os.environ["GITHUB_REPOSITORY"])
|
|
101
108
|
if args.update:
|
|
102
109
|
repo.update_branch(update=args.head_branch, upstream=args.base_branch)
|
|
103
|
-
pull_request = {
|
|
110
|
+
pull_request: dict[str, Any] = {
|
|
104
111
|
"base": args.base_branch,
|
|
105
112
|
"head": args.head_branch,
|
|
106
113
|
}
|
|
@@ -108,6 +115,8 @@ def main() -> int:
|
|
|
108
115
|
pull_request["title"] = args.title
|
|
109
116
|
if args.body:
|
|
110
117
|
pull_request["body"] = args.body
|
|
118
|
+
if args.draft:
|
|
119
|
+
pull_request["draft"] = True
|
|
111
120
|
# An empty model generates the title/body deterministically; a --model uses
|
|
112
121
|
# an LLM (falling back to deterministic generation on failure).
|
|
113
122
|
repo.create_pull_request(pull_request, model=args.model)
|
github_rest_api/scripts/utils.py
CHANGED
|
@@ -9,6 +9,8 @@ from typing import Any, Iterable
|
|
|
9
9
|
from dulwich import porcelain
|
|
10
10
|
from dulwich.repo import Repo
|
|
11
11
|
|
|
12
|
+
from github_rest_api.utils import as_str_sequence
|
|
13
|
+
|
|
12
14
|
|
|
13
15
|
def config_git(local_repo_dir: str | Path, user_email: str, user_name: str):
|
|
14
16
|
"""Config Git.
|
|
@@ -89,6 +91,7 @@ def find_project_root(path: Path | None = None) -> Path | None:
|
|
|
89
91
|
|
|
90
92
|
|
|
91
93
|
def get_toml_value(path: Path, keys: Sequence[str]) -> Any:
|
|
94
|
+
keys = as_str_sequence(keys)
|
|
92
95
|
if not keys or not path.exists():
|
|
93
96
|
return None
|
|
94
97
|
try:
|
github_rest_api/utils.py
CHANGED
|
@@ -14,6 +14,25 @@ def partition(pred, iterable):
|
|
|
14
14
|
return filter(pred, it1), filterfalse(pred, it2)
|
|
15
15
|
|
|
16
16
|
|
|
17
|
+
def as_str_sequence(value: str | Sequence[str]) -> Sequence[str]:
|
|
18
|
+
"""Wrap a bare string into a one-element sequence, leaving others untouched.
|
|
19
|
+
|
|
20
|
+
A `str` is itself a `Sequence[str]` of its own characters, so a caller who
|
|
21
|
+
passes a single value where a collection is expected is silently exploded
|
|
22
|
+
into one entry per character. No type checker flags this, because the
|
|
23
|
+
annotation really is satisfied.
|
|
24
|
+
|
|
25
|
+
An empty string yields an empty sequence rather than `[""]`, so a caller
|
|
26
|
+
passing an unset optional (from `os.getenv`, a config field or an argparse
|
|
27
|
+
default) keeps meaning "nothing" instead of "one empty entry".
|
|
28
|
+
|
|
29
|
+
:param value: A sequence of strings, or a single string.
|
|
30
|
+
"""
|
|
31
|
+
if isinstance(value, str):
|
|
32
|
+
return [value] if value else []
|
|
33
|
+
return value
|
|
34
|
+
|
|
35
|
+
|
|
17
36
|
def compile_patterns(patterns: str | Sequence[str] | None) -> list[re.Pattern[str]]:
|
|
18
37
|
"""Compile a list of regular expression patterns.
|
|
19
38
|
|
|
@@ -1,21 +1,21 @@
|
|
|
1
|
-
github_rest_api/__init__.py,sha256=
|
|
2
|
-
github_rest_api/github.py,sha256=
|
|
1
|
+
github_rest_api/__init__.py,sha256=cY28SPof_s_VfcsDe10lqfEFWFTpRkrmygz_EBsB-ec,410
|
|
2
|
+
github_rest_api/github.py,sha256=K2zdt0faIC0CodI-Iu1gM97JYrVq7bzy-8CYimewoXU,68602
|
|
3
3
|
github_rest_api/pr_content.py,sha256=og1VwsMtwqwl-HUJcJrkwjUjFPw9E1k5xDtJVMAXBRo,10039
|
|
4
|
-
github_rest_api/utils.py,sha256=
|
|
4
|
+
github_rest_api/utils.py,sha256=it5zHCNhXTut4M-PWR-BtWm63_rpFSc_xRc4rbDJ8Qw,3496
|
|
5
5
|
github_rest_api/scripts/__init__.py,sha256=NkHTngnnWXPc0JmO5mqVKjDf4JPofuwWjDW1SU3aE7Y,36
|
|
6
|
-
github_rest_api/scripts/utils.py,sha256=
|
|
6
|
+
github_rest_api/scripts/utils.py,sha256=y-qF_2cpf0KhMp9TWvpb6eBc8NW7Bu3fkQDdYlXG9Cc,4672
|
|
7
7
|
github_rest_api/scripts/cargo/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
8
|
github_rest_api/scripts/cargo/benchmark.py,sha256=wtxM9nQxELEUliWu8EGWqb42TRTOK9ufj4GxqxJeIS4,8541
|
|
9
9
|
github_rest_api/scripts/cargo/profiling.py,sha256=5jjTejhz3MmvOTz7cvdQGhkd6PB5salXnPwmhxeo2Ys,4279
|
|
10
10
|
github_rest_api/scripts/cargo/utils.py,sha256=tXp5mnVz0OSWfZSIP8COiB9rjX9XKILcCV62kf0ZAXw,339
|
|
11
11
|
github_rest_api/scripts/container/__init__.py,sha256=9faBHMYJhqU2BmVZootquPJIaYgW8hf6PfIlPnHyaU0,48
|
|
12
|
-
github_rest_api/scripts/container/build_container_images.py,sha256
|
|
12
|
+
github_rest_api/scripts/container/build_container_images.py,sha256=7ifuyqZeOjjgx1B-R1lUc53uU4kbmWXMhPkO18TnwkQ,8639
|
|
13
13
|
github_rest_api/scripts/container/config_container.py,sha256=BtOqgZlQFM9cWZMoHKvOzNsyXxCl_DLqYWbMqu1YOWM,2835
|
|
14
14
|
github_rest_api/scripts/container/update_version_containerfile.py,sha256=1PkuJkAsJBCEMdacMSndbuZKcmgUyXy6nBArnxKoPZk,4535
|
|
15
15
|
github_rest_api/scripts/github/__init__.py,sha256=tEU1SdCS51M4M13WoqXZt3rjGEAI_QgNljt5XAyQJ_w,38
|
|
16
|
-
github_rest_api/scripts/github/auto_merge_pull_request.py,sha256=
|
|
17
|
-
github_rest_api/scripts/github/create_github_repo.py,sha256=
|
|
18
|
-
github_rest_api/scripts/github/create_pull_request.py,sha256=
|
|
16
|
+
github_rest_api/scripts/github/auto_merge_pull_request.py,sha256=x7oUN6wvlM61mHUic3r2SKnPz2xxFcX15c5ZuAmU78A,3534
|
|
17
|
+
github_rest_api/scripts/github/create_github_repo.py,sha256=OYvlhzH65mIj41Z2daHs4EX-aoyG1Y1_tGSYYKSeb54,10812
|
|
18
|
+
github_rest_api/scripts/github/create_pull_request.py,sha256=vmvZkQcQDjYHnECUW5q7B4DHkPDVaHEqKQPb2IzxewY,4216
|
|
19
19
|
github_rest_api/scripts/github/release_on_github.py,sha256=9psaIv-GtrHdvE-tkw0yxCMDmW0bPts-db2vUxhoKXo,4368
|
|
20
20
|
github_rest_api/scripts/github/remove_branch.py,sha256=iKZ4Y_lQlSOIfifC4plqP-B6pEMnFrknGMvV1oLnuLc,3538
|
|
21
21
|
github_rest_api/scripts/github/workflows/auto_merge_pull_request.yaml,sha256=LOCYzTnubAqBbeGunSdnEt453ZLwJ2870YxRlcPJ7GE,768
|
|
@@ -25,7 +25,7 @@ github_rest_api/scripts/github/workflows/create_pr_to_main.yaml,sha256=mgHPlewkC
|
|
|
25
25
|
github_rest_api/scripts/github/workflows/remove_branch.yaml,sha256=KDpB76ovrhuRdP0HN5j6Th9gjIQWSDdh8b4b4yAgtoI,544
|
|
26
26
|
github_rest_api/scripts/github/workflows/python/lint.yaml,sha256=toP2oUdVIKyu_n20Dr14k5cJYxN8rPksfbX4sfflXxc,699
|
|
27
27
|
github_rest_api/scripts/github/workflows/python/test.yaml,sha256=86MljYEWX1E9kJYmhMCLwu46WNEWvoyO9PwqeRew540,887
|
|
28
|
-
github_rest_api-0.
|
|
29
|
-
github_rest_api-0.
|
|
30
|
-
github_rest_api-0.
|
|
31
|
-
github_rest_api-0.
|
|
28
|
+
github_rest_api-0.49.0.dist-info/METADATA,sha256=yXdqqS70hYq-w5uu3-dMaCTAAh0xmtNWmOWBFlZgU9Q,915
|
|
29
|
+
github_rest_api-0.49.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
30
|
+
github_rest_api-0.49.0.dist-info/entry_points.txt,sha256=OfbP9GuQC7SyGbymfCYrXEG-TCcKuu31v0EPNvHc1Q4,659
|
|
31
|
+
github_rest_api-0.49.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|