github-rest-api 0.48.0__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 +535 -27
- 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 +45 -8
- 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.48.0.dist-info → github_rest_api-0.49.0.dist-info}/METADATA +1 -1
- {github_rest_api-0.48.0.dist-info → github_rest_api-0.49.0.dist-info}/RECORD +12 -12
- {github_rest_api-0.48.0.dist-info → github_rest_api-0.49.0.dist-info}/WHEEL +0 -0
- {github_rest_api-0.48.0.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,11 +20,18 @@ 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
|
|
|
@@ -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.
|
|
@@ -284,12 +310,13 @@ class GitHub:
|
|
|
284
310
|
:param url: The endpoint URL to request.
|
|
285
311
|
:param raise_for_status: Whether to raise on a non-2xx response.
|
|
286
312
|
:param kwargs: Additional keyword arguments (e.g. `params`) forwarded
|
|
287
|
-
to `requests.get`.
|
|
313
|
+
to `requests.get`. `timeout` defaults to `DEFAULT_TIMEOUT`
|
|
314
|
+
seconds and must not be None (see `_apply_timeout`).
|
|
288
315
|
"""
|
|
316
|
+
_apply_timeout(kwargs)
|
|
289
317
|
resp = requests.get(
|
|
290
318
|
url=url,
|
|
291
319
|
headers=self._headers,
|
|
292
|
-
timeout=10,
|
|
293
320
|
**kwargs,
|
|
294
321
|
)
|
|
295
322
|
if raise_for_status:
|
|
@@ -304,22 +331,37 @@ class GitHub:
|
|
|
304
331
|
:param headers: Request headers; defaults to the standard auth headers.
|
|
305
332
|
:param raise_for_status: Whether to raise on a non-2xx response.
|
|
306
333
|
:param kwargs: Additional keyword arguments (e.g. `json`) forwarded
|
|
307
|
-
to `requests.post`.
|
|
334
|
+
to `requests.post`. `timeout` defaults to `DEFAULT_TIMEOUT`
|
|
335
|
+
seconds and must not be None (see `_apply_timeout`).
|
|
308
336
|
"""
|
|
309
337
|
if headers is None:
|
|
310
338
|
headers = self._headers
|
|
339
|
+
_apply_timeout(kwargs)
|
|
311
340
|
resp = requests.post(
|
|
312
341
|
url=url,
|
|
313
342
|
headers=headers,
|
|
314
|
-
timeout=10,
|
|
315
343
|
**kwargs,
|
|
316
344
|
)
|
|
317
345
|
if raise_for_status:
|
|
318
346
|
resp.raise_for_status()
|
|
319
347
|
return resp
|
|
320
348
|
|
|
321
|
-
def _delete(
|
|
322
|
-
|
|
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
|
+
)
|
|
323
365
|
if raise_for_status:
|
|
324
366
|
resp.raise_for_status()
|
|
325
367
|
return resp
|
|
@@ -331,12 +373,13 @@ class GitHub:
|
|
|
331
373
|
:param url: The endpoint URL to request.
|
|
332
374
|
:param raise_for_status: Whether to raise on a non-2xx response.
|
|
333
375
|
:param kwargs: Additional keyword arguments (e.g. `json`) forwarded
|
|
334
|
-
to `requests.put`.
|
|
376
|
+
to `requests.put`. `timeout` defaults to `DEFAULT_TIMEOUT`
|
|
377
|
+
seconds and must not be None (see `_apply_timeout`).
|
|
335
378
|
"""
|
|
379
|
+
_apply_timeout(kwargs)
|
|
336
380
|
resp = requests.put(
|
|
337
381
|
url=url,
|
|
338
382
|
headers=self._headers,
|
|
339
|
-
timeout=10,
|
|
340
383
|
**kwargs,
|
|
341
384
|
)
|
|
342
385
|
if raise_for_status:
|
|
@@ -348,12 +391,13 @@ class GitHub:
|
|
|
348
391
|
:param url: The endpoint URL to request.
|
|
349
392
|
:param raise_for_status: Whether to raise on a non-2xx response.
|
|
350
393
|
:param kwargs: Additional keyword arguments (e.g. `json`) forwarded
|
|
351
|
-
to `requests.patch`.
|
|
394
|
+
to `requests.patch`. `timeout` defaults to `DEFAULT_TIMEOUT`
|
|
395
|
+
seconds and must not be None (see `_apply_timeout`).
|
|
352
396
|
"""
|
|
397
|
+
_apply_timeout(kwargs)
|
|
353
398
|
resp = requests.patch(
|
|
354
399
|
url=url,
|
|
355
400
|
headers=self._headers,
|
|
356
|
-
timeout=10,
|
|
357
401
|
**kwargs,
|
|
358
402
|
)
|
|
359
403
|
if raise_for_status:
|
|
@@ -398,12 +442,56 @@ class IssueState(StrEnum):
|
|
|
398
442
|
ALL = "all"
|
|
399
443
|
|
|
400
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
|
+
|
|
401
454
|
class MergeMethod(StrEnum):
|
|
402
455
|
MERGE = "merge"
|
|
403
456
|
SQUASH = "squash"
|
|
404
457
|
REBASE = "rebase"
|
|
405
458
|
|
|
406
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
|
+
|
|
407
495
|
class Repository(GitHub):
|
|
408
496
|
"""Abstraction of a GitHub repository."""
|
|
409
497
|
|
|
@@ -421,6 +509,10 @@ class Repository(GitHub):
|
|
|
421
509
|
self._url_branches = f"{self._url_repo}/branches"
|
|
422
510
|
self._url_refs = f"{self._url_repo}/git/refs"
|
|
423
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"
|
|
424
516
|
self._url_releases = f"{self._url_repo}/releases"
|
|
425
517
|
self._url_secrets = f"{self._url_repo}/actions/secrets"
|
|
426
518
|
self._url_compare = f"{self._url_repo}/compare"
|
|
@@ -505,6 +597,28 @@ class Repository(GitHub):
|
|
|
505
597
|
"""
|
|
506
598
|
return self._extract_all(url=f"{self._url_pull}/{pr_number}/reviews", n=n)
|
|
507
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
|
+
|
|
508
622
|
def get_issues(
|
|
509
623
|
self, state: IssueState = IssueState.OPEN, n: int = 0
|
|
510
624
|
) -> list[dict[str, Any]]:
|
|
@@ -550,7 +664,7 @@ class Repository(GitHub):
|
|
|
550
664
|
|
|
551
665
|
def create_pull_request(
|
|
552
666
|
self,
|
|
553
|
-
json: dict[str,
|
|
667
|
+
json: dict[str, Any],
|
|
554
668
|
model: str = "",
|
|
555
669
|
) -> dict[str, Any] | None:
|
|
556
670
|
"""Create a pull request.
|
|
@@ -598,7 +712,7 @@ class Repository(GitHub):
|
|
|
598
712
|
def merge_pull_request(
|
|
599
713
|
self,
|
|
600
714
|
pr_number: int,
|
|
601
|
-
merge_method: MergeMethod
|
|
715
|
+
merge_method: MergeMethod = MergeMethod.MERGE,
|
|
602
716
|
sha: str = "",
|
|
603
717
|
) -> dict[str, Any]:
|
|
604
718
|
"""Merge a pull request in this repository.
|
|
@@ -662,9 +776,9 @@ class Repository(GitHub):
|
|
|
662
776
|
self,
|
|
663
777
|
pr_number: int,
|
|
664
778
|
*,
|
|
665
|
-
authors: Sequence[str],
|
|
666
|
-
approvers: Sequence[str],
|
|
667
|
-
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,
|
|
668
782
|
min_age_minutes: int = DEFAULT_MIN_AGE_MINUTES,
|
|
669
783
|
marker: str = DEFAULT_AUTO_MERGE_MARKER,
|
|
670
784
|
) -> str | None:
|
|
@@ -687,9 +801,12 @@ class Repository(GitHub):
|
|
|
687
801
|
the first failure so extra requests are avoided.
|
|
688
802
|
|
|
689
803
|
:param pr_number: The number of the pull request.
|
|
690
|
-
:param authors: Logins whose PRs are eligible for auto-merge
|
|
691
|
-
|
|
692
|
-
: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.
|
|
693
810
|
:param min_age_minutes: The minimum head-commit age in minutes.
|
|
694
811
|
:param marker: The marker substring an approver may comment to approve.
|
|
695
812
|
:return: The validated head SHA when the PR is eligible, else ``None``.
|
|
@@ -697,6 +814,9 @@ class Repository(GitHub):
|
|
|
697
814
|
commit checked here, so a push landing between this gate and the merge
|
|
698
815
|
is rejected rather than silently merged.
|
|
699
816
|
"""
|
|
817
|
+
authors = as_str_sequence(authors)
|
|
818
|
+
approvers = as_str_sequence(approvers)
|
|
819
|
+
allowed_types = as_str_sequence(allowed_types)
|
|
700
820
|
pr = self.get_pull_request(pr_number)
|
|
701
821
|
|
|
702
822
|
def skip(reason: str) -> None:
|
|
@@ -735,20 +855,23 @@ class Repository(GitHub):
|
|
|
735
855
|
def auto_merge_pull_requests(
|
|
736
856
|
self,
|
|
737
857
|
*,
|
|
738
|
-
authors: Sequence[str],
|
|
739
|
-
approvers: Sequence[str],
|
|
740
|
-
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,
|
|
741
861
|
min_age_minutes: int = DEFAULT_MIN_AGE_MINUTES,
|
|
742
862
|
marker: str = DEFAULT_AUTO_MERGE_MARKER,
|
|
743
|
-
merge_method: MergeMethod
|
|
863
|
+
merge_method: MergeMethod = MergeMethod.MERGE,
|
|
744
864
|
dry_run: bool = False,
|
|
745
865
|
) -> list[int]:
|
|
746
866
|
"""Auto-merge every open pull request that passes ``should_auto_merge``.
|
|
747
867
|
|
|
748
|
-
:param authors: Logins whose PRs are eligible for auto-merge
|
|
749
|
-
allowlist makes nothing
|
|
750
|
-
|
|
751
|
-
: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.
|
|
752
875
|
:param min_age_minutes: The minimum head-commit age in minutes.
|
|
753
876
|
:param marker: The marker substring an approver may comment to approve.
|
|
754
877
|
:param merge_method: The merge method to use (``merge``, ``squash`` or
|
|
@@ -758,6 +881,9 @@ class Repository(GitHub):
|
|
|
758
881
|
:return: The numbers of the PRs that were merged (or, under ``dry_run``,
|
|
759
882
|
that would have been merged).
|
|
760
883
|
"""
|
|
884
|
+
authors = as_str_sequence(authors)
|
|
885
|
+
approvers = as_str_sequence(approvers)
|
|
886
|
+
allowed_types = as_str_sequence(allowed_types)
|
|
761
887
|
eligible = []
|
|
762
888
|
for pr in self.get_pull_requests():
|
|
763
889
|
number = pr["number"]
|
|
@@ -937,6 +1063,389 @@ class Repository(GitHub):
|
|
|
937
1063
|
"""
|
|
938
1064
|
return self.pr_has_change(pr_number=pr_number, pred=pred)
|
|
939
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
|
+
|
|
940
1449
|
def get_issue_comments(self, issue_number: int, n: int = 0) -> list[dict[str, Any]]:
|
|
941
1450
|
"""List comments on an issue in this repository.
|
|
942
1451
|
|
|
@@ -954,7 +1463,6 @@ class Repository(GitHub):
|
|
|
954
1463
|
return self._post(
|
|
955
1464
|
url=f"{self._url_issues}/{issue_number}/comments",
|
|
956
1465
|
json={"body": body},
|
|
957
|
-
timeout=10,
|
|
958
1466
|
).json()
|
|
959
1467
|
|
|
960
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,12 +57,27 @@ 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,
|
|
@@ -83,7 +100,9 @@ def _init_local_repo(
|
|
|
83
100
|
if not (path / ".git").exists():
|
|
84
101
|
porcelain.init(path=path)
|
|
85
102
|
logger.info("Initialized empty Git repository in %s", path)
|
|
86
|
-
|
|
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.
|
|
87
106
|
initial_branch = _active_branch(path)
|
|
88
107
|
porcelain.add(repo=path, paths=["README.md"])
|
|
89
108
|
logger.info("Added README.md to staging")
|
|
@@ -99,11 +118,28 @@ def _init_local_repo(
|
|
|
99
118
|
porcelain.branch_delete(repo=path, name=initial_branch)
|
|
100
119
|
logger.info("Deleted initial branch '%s'", initial_branch)
|
|
101
120
|
else:
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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)
|
|
107
143
|
_ensure_remote(path, repo, protocol)
|
|
108
144
|
if push:
|
|
109
145
|
for branch in branches:
|
|
@@ -132,6 +168,7 @@ def create_github_repo(
|
|
|
132
168
|
push: bool,
|
|
133
169
|
branches: Sequence[str] = ("main",),
|
|
134
170
|
) -> None:
|
|
171
|
+
branches = as_str_sequence(branches)
|
|
135
172
|
token = token or os.getenv("GITHUB_TOKEN", "")
|
|
136
173
|
if not token:
|
|
137
174
|
token = getpass.getpass("Please enter your GitHub token: ")
|
|
@@ -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
|