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.
@@ -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 = "<!-- auto-merge: approved -->"
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
- if not _title_type_allowed(pr.get("title") or "", allowed_types):
181
- return "its title type is not auto-merge eligible"
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(self, url, raise_for_status: bool = True) -> requests.Response:
315
- resp = requests.delete(url=url, headers=self._headers, timeout=10)
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, 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 | str = MergeMethod.MERGE,
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
- :param approvers: Logins whose reviews/marker comments grant approval.
685
- :param allowed_types: Conventional-Commits title types eligible for auto-merge.
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 | str = MergeMethod.MERGE,
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. An empty
742
- allowlist makes nothing eligible (fail-safe).
743
- :param approvers: Logins whose reviews/marker comments grant approval.
744
- :param allowed_types: Conventional-Commits title types eligible for auto-merge.
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
- choices=[m.value for m in MergeMethod],
74
- default=MergeMethod.MERGE.value,
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"HEAD of the local repo at '{path}' is detached (not on any branch), "
59
- "which is unsupported (this happens, e.g., with a colocated Jujutsu repo). "
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
- if not porcelain.branch_list(path):
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
- local_branches = {b.decode() for b in porcelain.branch_list(path)}
99
- branches_to_push = [branch for branch in branches if branch in local_branches]
100
- if not branches_to_push:
101
- branches_to_push = [_active_branch(path)]
102
- for branch in branches_to_push:
103
- logger.info("Pushing branch '%s' to remote '%s'...", branch, repo)
104
- porcelain.push(
105
- repo=path,
106
- remote_location=_remote_url(repo, "https"),
107
- refspecs=[branch.encode()],
108
- username="x-access-token",
109
- password=token,
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 to remote (default: main).",
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)
@@ -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,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: github-rest-api
3
- Version: 0.47.2
3
+ Version: 0.49.0
4
4
  Summary: Simple wrapper of GitHub REST APIs.
5
5
  Author-email: Ben Du <longendu@yahoo.com>
6
6
  Classifier: Programming Language :: Python :: 3 :: Only
@@ -1,21 +1,21 @@
1
- github_rest_api/__init__.py,sha256=VVpiUHVLbjjJGNN88OmEvCwq4xmBnXv68T8Apk_lwVc,282
2
- github_rest_api/github.py,sha256=iAfhPLH9UGPtScZfcV1TCeY5XfUzAMqenf5FrSNplQc,45146
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=RspoIZik0g105KpyFXCf4epAtgu1dUsxwlwUSSGhXPI,2697
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=o5GzgSNyGHUIHhaaG5QUfXvv6fsTQPCHD9clGRNMggU,4588
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=-ZPPKVUpc0v-_5DuPldBykSCBo8ojzriIgrU5ODIZXg,8384
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=_5191Xt_b8DtxjIqHrdY-gKLxvkzWd8oWoSbF2uauc8,3527
17
- github_rest_api/scripts/github/create_github_repo.py,sha256=jH1uKOXKOxooO-PHMGlQ5YnIluWNqzRhkfgKdkH5UGU,8316
18
- github_rest_api/scripts/github/create_pull_request.py,sha256=gXtRRsxzegGxagHb6k3kxtRd3mt4QVwSPZQpsFnzz1Y,3968
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.47.2.dist-info/METADATA,sha256=7Z7N5_Ed4-TbTs2G9T17mSdtM74ZO2FU_8dyvfKFoOE,915
29
- github_rest_api-0.47.2.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
30
- github_rest_api-0.47.2.dist-info/entry_points.txt,sha256=OfbP9GuQC7SyGbymfCYrXEG-TCcKuu31v0EPNvHc1Q4,659
31
- github_rest_api-0.47.2.dist-info/RECORD,,
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,,