github-rest-api 0.49.0__tar.gz → 0.51.1__tar.gz

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.
Files changed (66) hide show
  1. {github_rest_api-0.49.0 → github_rest_api-0.51.1}/.github/workflows/auto_merge_pull_request.yaml +5 -22
  2. github_rest_api-0.51.1/.github/workflows/check_workflow_template.yaml +20 -0
  3. github_rest_api-0.51.1/.github/workflows/claude.yaml +27 -0
  4. github_rest_api-0.51.1/.github/workflows/create_pr_dev_to_main.yaml +9 -0
  5. github_rest_api-0.51.1/.github/workflows/create_pr_to_dev.yaml +11 -0
  6. github_rest_api-0.51.1/.github/workflows/create_pr_to_main.yaml +10 -0
  7. github_rest_api-0.51.1/.github/workflows/pr-workflow.yaml +34 -0
  8. github_rest_api-0.51.1/.github/workflows/release.yaml +12 -0
  9. github_rest_api-0.51.1/.github/workflows/remove_branch.yaml +9 -0
  10. github_rest_api-0.49.0/GEMINI.md → github_rest_api-0.51.1/AGENTS.md +1 -1
  11. github_rest_api-0.51.1/CLAUDE.md +1 -0
  12. {github_rest_api-0.49.0 → github_rest_api-0.51.1}/PKG-INFO +3 -3
  13. {github_rest_api-0.49.0 → github_rest_api-0.51.1}/github_rest_api/github.py +26 -0
  14. {github_rest_api-0.49.0 → github_rest_api-0.51.1}/github_rest_api/scripts/github/create_github_repo.py +117 -47
  15. {github_rest_api-0.49.0 → github_rest_api-0.51.1}/github_rest_api/scripts/github/release_on_github.py +16 -12
  16. github_rest_api-0.51.1/github_rest_api/scripts/github/upload_image_to_issue.py +533 -0
  17. {github_rest_api-0.49.0 → github_rest_api-0.51.1}/github_rest_api/scripts/utils.py +77 -1
  18. {github_rest_api-0.49.0 → github_rest_api-0.51.1}/pyproject.toml +7 -2
  19. github_rest_api-0.51.1/tests/test_create_github_repo.py +326 -0
  20. {github_rest_api-0.49.0 → github_rest_api-0.51.1}/tests/test_github.py +68 -0
  21. github_rest_api-0.51.1/tests/test_release_on_github.py +121 -0
  22. github_rest_api-0.51.1/tests/test_upload_image_to_issue.py +701 -0
  23. {github_rest_api-0.49.0 → github_rest_api-0.51.1}/tests/test_utils.py +36 -0
  24. {github_rest_api-0.49.0 → github_rest_api-0.51.1}/uv.lock +2 -2
  25. github_rest_api-0.49.0/.github/workflows/claude-code-review.yml +0 -67
  26. github_rest_api-0.49.0/.github/workflows/claude.yml +0 -50
  27. github_rest_api-0.49.0/.github/workflows/create_pr_to_main.yaml +0 -20
  28. github_rest_api-0.49.0/.github/workflows/lint.yaml +0 -34
  29. github_rest_api-0.49.0/.github/workflows/release.yaml +0 -22
  30. github_rest_api-0.49.0/.github/workflows/remove_branch.yaml +0 -18
  31. github_rest_api-0.49.0/.github/workflows/test.yaml +0 -27
  32. github_rest_api-0.49.0/github_rest_api/scripts/github/workflows/auto_merge_pull_request.yaml +0 -30
  33. github_rest_api-0.49.0/github_rest_api/scripts/github/workflows/create_pr_dev_to_main.yaml +0 -19
  34. github_rest_api-0.49.0/github_rest_api/scripts/github/workflows/create_pr_to_dev.yaml +0 -21
  35. github_rest_api-0.49.0/github_rest_api/scripts/github/workflows/create_pr_to_main.yaml +0 -20
  36. github_rest_api-0.49.0/github_rest_api/scripts/github/workflows/python/lint.yaml +0 -27
  37. github_rest_api-0.49.0/github_rest_api/scripts/github/workflows/python/test.yaml +0 -31
  38. github_rest_api-0.49.0/github_rest_api/scripts/github/workflows/remove_branch.yaml +0 -18
  39. github_rest_api-0.49.0/tests/test_create_github_repo.py +0 -128
  40. github_rest_api-0.49.0/tests/test_release_on_github.py +0 -64
  41. {github_rest_api-0.49.0 → github_rest_api-0.51.1}/.devcontainer/devcontainer.json +0 -0
  42. {github_rest_api-0.49.0 → github_rest_api-0.51.1}/.gitignore +0 -0
  43. {github_rest_api-0.49.0 → github_rest_api-0.51.1}/README.md +0 -0
  44. {github_rest_api-0.49.0 → github_rest_api-0.51.1}/github_rest_api/__init__.py +0 -0
  45. {github_rest_api-0.49.0 → github_rest_api-0.51.1}/github_rest_api/pr_content.py +0 -0
  46. {github_rest_api-0.49.0 → github_rest_api-0.51.1}/github_rest_api/scripts/__init__.py +0 -0
  47. {github_rest_api-0.49.0 → github_rest_api-0.51.1}/github_rest_api/scripts/cargo/__init__.py +0 -0
  48. {github_rest_api-0.49.0 → github_rest_api-0.51.1}/github_rest_api/scripts/cargo/benchmark.py +0 -0
  49. {github_rest_api-0.49.0 → github_rest_api-0.51.1}/github_rest_api/scripts/cargo/profiling.py +0 -0
  50. {github_rest_api-0.49.0 → github_rest_api-0.51.1}/github_rest_api/scripts/cargo/utils.py +0 -0
  51. {github_rest_api-0.49.0 → github_rest_api-0.51.1}/github_rest_api/scripts/container/__init__.py +0 -0
  52. {github_rest_api-0.49.0 → github_rest_api-0.51.1}/github_rest_api/scripts/container/build_container_images.py +0 -0
  53. {github_rest_api-0.49.0 → github_rest_api-0.51.1}/github_rest_api/scripts/container/config_container.py +0 -0
  54. {github_rest_api-0.49.0 → github_rest_api-0.51.1}/github_rest_api/scripts/container/update_version_containerfile.py +0 -0
  55. {github_rest_api-0.49.0 → github_rest_api-0.51.1}/github_rest_api/scripts/github/__init__.py +0 -0
  56. {github_rest_api-0.49.0 → github_rest_api-0.51.1}/github_rest_api/scripts/github/auto_merge_pull_request.py +0 -0
  57. {github_rest_api-0.49.0 → github_rest_api-0.51.1}/github_rest_api/scripts/github/create_pull_request.py +0 -0
  58. {github_rest_api-0.49.0 → github_rest_api-0.51.1}/github_rest_api/scripts/github/remove_branch.py +0 -0
  59. {github_rest_api-0.49.0 → github_rest_api-0.51.1}/github_rest_api/utils.py +0 -0
  60. {github_rest_api-0.49.0 → github_rest_api-0.51.1}/memory/MEMORY.md +0 -0
  61. {github_rest_api-0.49.0 → github_rest_api-0.51.1}/memory/feedback_test_runner.md +0 -0
  62. {github_rest_api-0.49.0 → github_rest_api-0.51.1}/tests/__init__.py +0 -0
  63. {github_rest_api-0.49.0 → github_rest_api-0.51.1}/tests/test_auto_merge_pull_request.py +0 -0
  64. {github_rest_api-0.49.0 → github_rest_api-0.51.1}/tests/test_build_container_images.py +0 -0
  65. {github_rest_api-0.49.0 → github_rest_api-0.51.1}/tests/test_create_pull_request.py +0 -0
  66. {github_rest_api-0.49.0 → github_rest_api-0.51.1}/tests/test_pr_content.py +0 -0
@@ -13,9 +13,7 @@ on:
13
13
  # Renaming any of these silently disables the trigger.
14
14
  workflow_run:
15
15
  workflows:
16
- - Claude Code Review
17
- - Lint Code
18
- - Run Tests
16
+ - PR Workflow
19
17
  types:
20
18
  - completed
21
19
  # Kept for approvals that come from a human rather than from Claude.
@@ -60,22 +58,7 @@ jobs:
60
58
  if: |
61
59
  github.event_name != 'workflow_run'
62
60
  || github.event.workflow_run.conclusion == 'success'
63
- runs-on: ubuntu-latest
64
- # The concurrency group above serializes every run, so a hung run would
65
- # block all merges until the default 6h timeout.
66
- timeout-minutes: 10
67
- steps:
68
- - name: Install uv
69
- run: |
70
- curl -LsSf https://astral.sh/uv/install.sh | sudo env UV_INSTALL_DIR="/usr/local/bin" sh
71
- - name: Auto Merge PR
72
- # `--allowed-types` is intentionally left at the upstream default
73
- # (build, chore, ci, docs, deps) so that only simple PRs auto-merge;
74
- # anything substantive, `feat` and `fix` included, is merged by hand.
75
- run: |
76
- uvx --from github-rest-api@latest auto_merge_pull_request \
77
- --authors dclong \
78
- --approvers dclong 'claude[bot]' 'gemini-code-assist[bot]' \
79
- --marker 'AUTO_MERGE_APPROVED' \
80
- ${{ inputs.dry_run && '--dry-run' || '' }} \
81
- --token ${{ secrets.ACTIONS_TOKEN }}
61
+ uses: legendu-net/.github/.github/workflows/auto_merge_pull_request.yaml@main
62
+ with:
63
+ dry_run: ${{ inputs.dry_run || false }}
64
+ secrets: inherit
@@ -0,0 +1,20 @@
1
+ name: Check Workflow Templates
2
+
3
+ on:
4
+ pull_request:
5
+ paths:
6
+ - .github/workflows/**
7
+
8
+ jobs:
9
+ check_workflow_template:
10
+ runs-on: ubuntu-latest
11
+ steps:
12
+ - uses: actions/checkout@v6
13
+ - name: Check workflow templates use .yaml extension
14
+ run: |
15
+ yml_files=$(find .github/workflows/ -name '*.yml')
16
+ if [ -n "$yml_files" ]; then
17
+ echo "The following workflow templates must use the .yaml extension instead of .yml:"
18
+ echo "$yml_files"
19
+ exit 1
20
+ fi
@@ -0,0 +1,27 @@
1
+ name: Claude Code
2
+
3
+ on:
4
+ issue_comment:
5
+ types: [created]
6
+ pull_request_review_comment:
7
+ types: [created]
8
+ issues:
9
+ types: [opened, assigned]
10
+ pull_request_review:
11
+ types: [submitted]
12
+
13
+ jobs:
14
+ claude:
15
+ if: |
16
+ (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
17
+ (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
18
+ (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
19
+ (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
20
+ permissions:
21
+ actions: read
22
+ contents: read
23
+ issues: read
24
+ pull-requests: read
25
+ id-token: write
26
+ uses: legendu-net/.github/.github/workflows/claude.yml@main
27
+ secrets: inherit
@@ -0,0 +1,9 @@
1
+ name: Create PR From dev To main
2
+ on:
3
+ push:
4
+ branches:
5
+ - dev
6
+ jobs:
7
+ create_pr_dev_to_main:
8
+ uses: legendu-net/.github/.github/workflows/create_pr_dev_to_main.yaml@main
9
+ secrets: inherit
@@ -0,0 +1,11 @@
1
+ name: Create PR to dev
2
+ on:
3
+ push:
4
+ branches-ignore:
5
+ - main
6
+ - dev
7
+ - gh-pages*
8
+ jobs:
9
+ create_pr_to_dev:
10
+ uses: legendu-net/.github/.github/workflows/create_pr_to_dev.yaml@main
11
+ secrets: inherit
@@ -0,0 +1,10 @@
1
+ name: Create PR To main
2
+ on:
3
+ push:
4
+ branches-ignore:
5
+ - main
6
+ - _**
7
+ jobs:
8
+ create_pr_main:
9
+ uses: legendu-net/.github/.github/workflows/create_pr_to_main.yaml@main
10
+ secrets: inherit
@@ -0,0 +1,34 @@
1
+ name: PR Workflow
2
+
3
+ on:
4
+ pull_request:
5
+ types: [opened, synchronize, ready_for_review, reopened]
6
+ branches:
7
+ - dev
8
+ - main
9
+
10
+ jobs:
11
+ lint_code:
12
+ uses: legendu-net/.github/.github/workflows/lint.yaml@main
13
+ secrets: inherit
14
+
15
+ test_code:
16
+ permissions:
17
+ contents: read
18
+ pull-requests: read
19
+ uses: legendu-net/.github/.github/workflows/test.yaml@main
20
+ secrets: inherit
21
+
22
+ claude-review:
23
+ needs: [lint_code, test_code]
24
+ permissions:
25
+ contents: read
26
+ issues: write
27
+ pull-requests: write
28
+ id-token: write
29
+ uses: legendu-net/.github/.github/workflows/claude-code-review.yml@main
30
+ with:
31
+ pr_number: ${{ github.event.pull_request.number }}
32
+ draft: ${{ github.event.pull_request.draft }}
33
+ head_repo_full_name: ${{ github.event.pull_request.head.repo.full_name }}
34
+ secrets: inherit
@@ -0,0 +1,12 @@
1
+ name: Release
2
+ on:
3
+ release:
4
+ types: [published]
5
+ jobs:
6
+ release:
7
+ permissions:
8
+ contents: write
9
+ issues: write
10
+ pull-requests: write
11
+ uses: legendu-net/.github/.github/workflows/release.yaml@main
12
+ secrets: inherit
@@ -0,0 +1,9 @@
1
+ name: Remove Branches
2
+ on:
3
+ schedule:
4
+ - cron: '0 3 * * *'
5
+ timezone: 'America/Los_Angeles'
6
+ jobs:
7
+ remove_branch:
8
+ uses: legendu-net/.github/.github/workflows/remove_branch.yaml@main
9
+ secrets: inherit
@@ -7,7 +7,7 @@ A simple Python wrapper for GitHub REST APIs, optimized for use in GitHub Action
7
7
  - **Purpose:** Provide a streamlined interface for interacting with GitHub's REST API
8
8
  and performing Git operations within automation scripts.
9
9
  - **Main Technologies:**
10
- - **Python 3.12+**: Core language.
10
+ - **Python 3.12.7+**: Core language.
11
11
  - **requests**: For HTTP interactions with the GitHub API.
12
12
  - **dulwich**: A pure-Python implementation of Git for repository operations.
13
13
  - **tenacity**: For retry logic on API requests.
@@ -0,0 +1 @@
1
+ AGENTS.md
@@ -1,13 +1,13 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: github-rest-api
3
- Version: 0.49.0
3
+ Version: 0.51.1
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
7
7
  Classifier: Programming Language :: Python :: 3.12
8
8
  Classifier: Programming Language :: Python :: 3.13
9
9
  Classifier: Programming Language :: Python :: 3.14
10
- Requires-Python: <4,>=3.12
10
+ Requires-Python: <4,>=3.12.7
11
11
  Requires-Dist: dulwich>=0.25.1
12
12
  Requires-Dist: psutil>=5.9.4
13
13
  Requires-Dist: pynacl>=1.5
@@ -1465,6 +1465,32 @@ class Repository(GitHub):
1465
1465
  json={"body": body},
1466
1466
  ).json()
1467
1467
 
1468
+ def create_issue_comment_image(
1469
+ self, issue_number: int, url: str, alt: str = "", body: str = ""
1470
+ ) -> dict[str, Any]:
1471
+ """Add a comment containing an image to an issue.
1472
+
1473
+ GitHub has no public API for the attachment upload that its web UI
1474
+ performs on drag and drop, so the image must already be hosted
1475
+ somewhere reachable; this method only references it.
1476
+
1477
+ :param issue_number: The number of the issue.
1478
+ :param url: The URL of an already uploaded image.
1479
+ :param alt: Alt text of the image.
1480
+ :param body: Optional text placed above the image.
1481
+ """
1482
+ # A bracket in the alt text would close the markdown label early and
1483
+ # degrade the whole image to literal text, so escape both brackets
1484
+ # (and the escape character itself, which must come first).
1485
+ alt = alt.replace("\\", "\\\\").replace("[", "\\[").replace("]", "\\]")
1486
+ # The URL is wrapped in angle brackets so that spaces and unbalanced
1487
+ # parentheses, which are common in URLs derived from file names, do
1488
+ # not terminate the markdown link early.
1489
+ image = f"![{alt}](<{url}>)"
1490
+ return self.create_issue_comment(
1491
+ issue_number, f"{body}\n\n{image}" if body else image
1492
+ )
1493
+
1468
1494
  def archive(self) -> requests.Response:
1469
1495
  return self._patch(
1470
1496
  url=self._url_repo,
@@ -1,27 +1,28 @@
1
1
  """Add a GitHub repository and initialize a local Git repository with workflows."""
2
2
 
3
3
  import argparse
4
- import getpass
5
4
  import logging
6
- import os
7
- import shutil
8
5
  import sys
9
6
  from collections.abc import Sequence
10
7
  from pathlib import Path
11
8
 
9
+ import requests
12
10
  from dulwich import porcelain
13
11
  from dulwich.refs import HEADREF, LOCAL_BRANCH_PREFIX, Ref
14
12
 
15
13
  from github_rest_api import Organization, User
14
+ from github_rest_api.github import DEFAULT_TIMEOUT, URL_API, build_http_headers
15
+ from github_rest_api.scripts.utils import resolve_github_token, validate_repo
16
16
  from github_rest_api.utils import as_str_sequence
17
17
 
18
18
  logger = logging.getLogger(__name__)
19
19
 
20
+ # Workflows that configure this repository itself rather than the repositories
21
+ # created from these templates.
22
+ _REPO_ONLY_WORKFLOWS = frozenset({"check_workflow_template.yaml"})
20
23
 
21
- def _validate_repo(repo: str) -> None:
22
- parts = repo.split("/")
23
- if len(parts) != 2 or not parts[0] or not parts[1]:
24
- raise ValueError(f"Invalid repo format '{repo}'. Expected 'owner/repo'.")
24
+ # The default GitHub repo to copy workflow templates from.
25
+ DEFAULT_WORKFLOWS_REPO = "legendu-net/github_rest_api"
25
26
 
26
27
 
27
28
  def _create_remote_repo(
@@ -45,11 +46,29 @@ def _remote_url(repo: str, protocol: str) -> str:
45
46
 
46
47
 
47
48
  def _ensure_remote(path: Path, repo: str, protocol: str) -> None:
49
+ url = _remote_url(repo, protocol)
48
50
  try:
49
- porcelain.remote_add(path, "origin", _remote_url(repo, protocol))
51
+ porcelain.remote_add(path, "origin", url)
50
52
  logger.info("Added remote 'origin' for %s", repo)
51
53
  except porcelain.RemoteExists:
52
54
  pass
55
+ # `porcelain.remote_add` only writes the remote's `url`, unlike `git remote
56
+ # add`/`git clone`, which also set a `fetch` refspec. Without it, `git
57
+ # fetch`/`jj git fetch` never update `refs/remotes/origin/*`, so local
58
+ # branches silently stop tracking the remote. Repair it for a pre-existing
59
+ # 'origin' too (e.g. added by an older version of this script), but only
60
+ # when it already points at this repo, so an unrelated 'origin' remote is
61
+ # left untouched.
62
+ with porcelain.open_repo_closing(path) as r:
63
+ c = r.get_config()
64
+ try:
65
+ existing_url = c.get((b"remote", b"origin"), b"url")
66
+ except KeyError:
67
+ return
68
+ if existing_url != url.encode():
69
+ return
70
+ c.set((b"remote", b"origin"), b"fetch", b"+refs/heads/*:refs/remotes/origin/*")
71
+ c.write_to_path()
53
72
 
54
73
 
55
74
  def _active_branch(path: Path) -> str:
@@ -80,12 +99,12 @@ def _head_and_branches(path: Path) -> tuple[str | None, set[str]]:
80
99
 
81
100
  def _init_local_repo(
82
101
  repo: str,
83
- language: str,
84
102
  dir_: str,
85
103
  token: str,
86
104
  protocol: str,
87
105
  push: bool,
88
106
  branches: Sequence[str] = ("main",),
107
+ workflows_repo: str = DEFAULT_WORKFLOWS_REPO,
89
108
  ) -> None:
90
109
  branches = list(dict.fromkeys(branches))
91
110
  if not branches:
@@ -154,64 +173,114 @@ def _init_local_repo(
154
173
  logger.info("Successfully pushed branch '%s'.", branch)
155
174
  else:
156
175
  logger.info("Skipping push (pass --push to push branches to the remote).")
157
- _add_workflow(path, language)
176
+ _add_workflow(path, workflows_repo=workflows_repo, token=token)
158
177
 
159
178
 
160
179
  def create_github_repo(
161
180
  repo: str,
162
181
  private: bool,
163
- language: str,
164
182
  is_owner_user: bool,
165
183
  dir_: str,
166
184
  token: str,
167
185
  protocol: str,
168
186
  push: bool,
169
187
  branches: Sequence[str] = ("main",),
188
+ workflows_repo: str = DEFAULT_WORKFLOWS_REPO,
170
189
  ) -> None:
171
190
  branches = as_str_sequence(branches)
172
- token = token or os.getenv("GITHUB_TOKEN", "")
173
- if not token:
174
- token = getpass.getpass("Please enter your GitHub token: ")
175
- if not token:
176
- raise ValueError(
177
- "No GitHub token is provided (via $GITHUB_TOKEN, --token or at prompt)."
178
- )
191
+ token = resolve_github_token(token)
179
192
  repo = repo.strip()
180
- _validate_repo(repo)
193
+ validate_repo(repo)
181
194
  _create_remote_repo(
182
195
  repo=repo, private=private, token=token, is_owner_user=is_owner_user
183
196
  )
184
197
  _init_local_repo(
185
198
  repo=repo,
186
- language=language,
187
199
  dir_=dir_,
188
200
  token=token,
189
201
  branches=branches,
190
202
  protocol=protocol,
191
203
  push=push,
204
+ workflows_repo=workflows_repo,
192
205
  )
193
206
 
194
207
 
195
- def _add_workflow(path: Path, language: str, workflow_dir: Path | None = None) -> None:
196
- if workflow_dir is None:
197
- workflow_dir = Path(__file__).parent / "workflows"
208
+ def _list_workflow_entries(workflows_repo: str, token: str) -> list[dict]:
209
+ """List the workflow template file entries available in a GitHub repo.
210
+
211
+ :param workflows_repo: The GitHub repo (in the format of owner/repo) to
212
+ list workflow templates from.
213
+ :param token: The GitHub token used to authenticate the request.
214
+ """
215
+ url = f"{URL_API}/repos/{workflows_repo}/contents/.github/workflows"
216
+ resp = requests.get(
217
+ url=url, headers=build_http_headers(token), timeout=DEFAULT_TIMEOUT
218
+ )
219
+ resp.raise_for_status()
220
+ return [
221
+ entry
222
+ for entry in resp.json()
223
+ if entry["type"] == "file"
224
+ and entry["name"].endswith(".yaml")
225
+ and entry["name"] not in _REPO_ONLY_WORKFLOWS
226
+ ]
227
+
228
+
229
+ def _download_workflow(workflows_repo: str, entry: dict, token: str) -> str:
230
+ """Download the raw content of a single workflow template.
231
+
232
+ Prefers the entry's ``download_url`` (served from
233
+ raw.githubusercontent.com, which has a much higher rate limit than the
234
+ api.github.com contents endpoint used to list the entries), falling back
235
+ to a raw content request against the contents API for the rare case
236
+ where GitHub omits it.
237
+
238
+ :param workflows_repo: The GitHub repo (in the format of owner/repo) to
239
+ download the workflow template from.
240
+ :param entry: A file entry as returned by ``_list_workflow_entries``.
241
+ :param token: The GitHub token used to authenticate the request.
242
+ """
243
+ download_url = entry.get("download_url")
244
+ if download_url:
245
+ # No Authorization header here: raw.githubusercontent.com already
246
+ # embeds any access it needs (e.g. a signed token for a private repo)
247
+ # in the URL itself, and forwarding an Authorization header scoped to
248
+ # a *different* repo makes it fail closed with a 404 even for public
249
+ # content.
250
+ resp = requests.get(url=download_url, timeout=DEFAULT_TIMEOUT)
251
+ else:
252
+ url = f"{URL_API}/repos/{workflows_repo}/contents/.github/workflows/{
253
+ entry['name']
254
+ }"
255
+ headers = build_http_headers(token) | {
256
+ "Accept": "application/vnd.github.raw+json"
257
+ }
258
+ resp = requests.get(url=url, headers=headers, timeout=DEFAULT_TIMEOUT)
259
+ resp.raise_for_status()
260
+ return resp.text
261
+
262
+
263
+ def _add_workflow(path: Path, workflows_repo: str, token: str) -> None:
264
+ """Download workflow templates from a GitHub repo into a local repo.
265
+
266
+ :param path: The local repo directory to add workflow templates to.
267
+ :param workflows_repo: The GitHub repo (in the format of owner/repo) to
268
+ copy workflow templates from. An empty string skips this step.
269
+ :param token: The GitHub token used to authenticate the requests.
270
+ """
271
+ if not workflows_repo:
272
+ logger.info("Skipping workflow templates (workflows_repo is empty).")
273
+ return
274
+ entries = _list_workflow_entries(workflows_repo, token)
275
+ if not entries:
276
+ raise FileNotFoundError(f"No workflow templates found in '{workflows_repo}'.")
198
277
  dir_dest = path / ".github" / "workflows"
199
278
  dir_dest.mkdir(parents=True, exist_ok=True)
200
- for yaml in workflow_dir.glob("*.yaml"):
201
- if not (dir_dest / yaml.name).exists():
202
- shutil.copy2(yaml, dir_dest)
203
- logger.info("Copied workflow %s to %s", yaml.name, dir_dest)
204
- if not language:
205
- return
206
- lang_dir = workflow_dir / language
207
- if not lang_dir.exists():
208
- return
209
- for yaml in lang_dir.glob("*.yaml"):
210
- if not (dir_dest / yaml.name).exists():
211
- shutil.copy2(yaml, dir_dest)
212
- logger.info(
213
- "Copied %s specific workflow %s to %s", language, yaml.name, dir_dest
214
- )
279
+ for entry in entries:
280
+ dest = dir_dest / entry["name"]
281
+ if not dest.exists():
282
+ dest.write_text(_download_workflow(workflows_repo, entry, token))
283
+ logger.info("Downloaded workflow %s from %s", entry["name"], workflows_repo)
215
284
 
216
285
 
217
286
  def parse_args(args=None, namespace=None):
@@ -250,14 +319,6 @@ def parse_args(args=None, namespace=None):
250
319
  action="store_false",
251
320
  help="Whether to create the repository as public.",
252
321
  )
253
- parser.add_argument(
254
- "-l",
255
- "--lang",
256
- "--language",
257
- dest="language",
258
- default="",
259
- help="The language of the GitHub repository.",
260
- )
261
322
  parser.add_argument(
262
323
  "-d",
263
324
  "--dir",
@@ -287,6 +348,15 @@ def parse_args(args=None, namespace=None):
287
348
  action="store_true",
288
349
  help="Push branches to the remote (by default, nothing is pushed).",
289
350
  )
351
+ parser.add_argument(
352
+ "-w",
353
+ "--workflows-repo",
354
+ dest="workflows_repo",
355
+ default=DEFAULT_WORKFLOWS_REPO,
356
+ help="The GitHub repo (in the format of owner/repo) to copy workflow "
357
+ f"templates from (default: {DEFAULT_WORKFLOWS_REPO}). "
358
+ "Pass an empty string to skip copying workflow templates.",
359
+ )
290
360
  parser.add_argument(
291
361
  "-v",
292
362
  "--verbose",
@@ -304,13 +374,13 @@ def main() -> int:
304
374
  create_github_repo(
305
375
  repo=args.repo,
306
376
  private=args.private,
307
- language=args.language,
308
377
  is_owner_user=args.is_owner_user,
309
378
  dir_=args.dir,
310
379
  token=args.token,
311
380
  branches=args.branches,
312
381
  protocol=args.protocol,
313
382
  push=args.push,
383
+ workflows_repo=args.workflows_repo,
314
384
  )
315
385
  except Exception as e:
316
386
  logger.error("Failed to create GitHub repo: %s", e, exc_info=args.verbose)
@@ -1,19 +1,20 @@
1
- import argparse
2
- import getpass
3
- import os
4
1
  import re
5
2
  import sys
6
3
  from pathlib import Path
7
4
 
8
5
  from github_rest_api import Repository
9
6
  from github_rest_api.scripts.utils import (
7
+ RedactingArgumentParser,
10
8
  find_project_root,
11
9
  get_project_version,
12
10
  get_repo,
11
+ reject_github_token,
12
+ resolve_github_token,
13
13
  )
14
14
 
15
15
 
16
16
  def _get_release_tag(tag: str, root: Path, validate: bool = True) -> str:
17
+ reject_github_token("--tag", tag)
17
18
  tag = tag.strip()
18
19
  if not tag:
19
20
  tag = get_project_version(root).strip()
@@ -54,7 +55,14 @@ def release_on_github(
54
55
  :param token: GitHub token.
55
56
  If not specified, the GITHUB_TOKEN environment variable is used.
56
57
  :param validate: If True, validate the tag against semantic versioning format before creating the release.
58
+ :raises ValueError: If a value that looks like a GitHub token is specified
59
+ for anything other than the token itself.
57
60
  """
61
+ # A release is public, so a token mistyped into any of these would be
62
+ # published. -t is the tag on this script rather than the token, which
63
+ # makes that slip easy; --no-validate would otherwise let it through.
64
+ reject_github_token("--branch", branch)
65
+ reject_github_token("--notes", notes)
58
66
  root = find_project_root()
59
67
  if not root:
60
68
  raise FileNotFoundError("Could not find project root (no .git found).")
@@ -65,13 +73,7 @@ def release_on_github(
65
73
  if not repo_name:
66
74
  raise ValueError("Could not find GitHub repository name.")
67
75
 
68
- token = token or os.getenv("GITHUB_TOKEN", "")
69
- if not token:
70
- token = getpass.getpass("Please enter your GitHub token: ")
71
- if not token:
72
- raise ValueError(
73
- "No GitHub token is provided (via $GITHUB_TOKEN, --token or at prompt)."
74
- )
76
+ token = resolve_github_token(token)
75
77
  repo = Repository(token=token, repo=repo_name)
76
78
  data = {
77
79
  "tag_name": tag,
@@ -87,7 +89,7 @@ def release_on_github(
87
89
 
88
90
 
89
91
  def parse_args(args=None, namespace=None):
90
- parser = argparse.ArgumentParser(
92
+ parser = RedactingArgumentParser(
91
93
  description="Make a release of the project on GitHub."
92
94
  )
93
95
  parser.add_argument(
@@ -97,7 +99,9 @@ def parse_args(args=None, namespace=None):
97
99
  help="The branch (default to main) from which to make the release.",
98
100
  )
99
101
  parser.add_argument(
100
- "-t",
102
+ # No short option on purpose: -t reads as the token to anyone used to
103
+ # the other scripts, and the tag becomes the public name of the
104
+ # release, so that slip would publish the token.
101
105
  "--tag",
102
106
  default="",
103
107
  help="The tag for the release. If not specified, the version from project configuration is used.",