github-rest-api 0.38.0__tar.gz → 0.39.0__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 (45) hide show
  1. github_rest_api-0.39.0/.claude/settings.json +9 -0
  2. {github_rest_api-0.38.0/github_rest_api/actions/github → github_rest_api-0.39.0/.github}/workflows/create_pr_to_main.yml +1 -2
  3. {github_rest_api-0.38.0 → github_rest_api-0.39.0}/.github/workflows/remove_branch.yml +1 -2
  4. {github_rest_api-0.38.0 → github_rest_api-0.39.0}/GEMINI.md +3 -3
  5. {github_rest_api-0.38.0 → github_rest_api-0.39.0}/PKG-INFO +1 -1
  6. {github_rest_api-0.38.0/github_rest_api/actions → github_rest_api-0.39.0/github_rest_api/scripts}/container/build_container_images.py +30 -13
  7. {github_rest_api-0.38.0/github_rest_api/actions → github_rest_api-0.39.0/github_rest_api/scripts}/container/config_container.py +12 -6
  8. {github_rest_api-0.38.0/github_rest_api/actions → github_rest_api-0.39.0/github_rest_api/scripts}/container/update_version_containerfile.py +21 -15
  9. {github_rest_api-0.38.0/github_rest_api/actions → github_rest_api-0.39.0/github_rest_api/scripts}/github/add_github_repo.py +18 -13
  10. {github_rest_api-0.38.0/github_rest_api/actions → github_rest_api-0.39.0/github_rest_api/scripts}/github/create_pull_request.py +5 -3
  11. {github_rest_api-0.38.0/github_rest_api/actions → github_rest_api-0.39.0/github_rest_api/scripts}/github/release_on_github.py +85 -93
  12. {github_rest_api-0.38.0/github_rest_api/actions → github_rest_api-0.39.0/github_rest_api/scripts}/github/remove_branch.py +9 -3
  13. {github_rest_api-0.38.0/github_rest_api/actions → github_rest_api-0.39.0/github_rest_api/scripts}/github/workflows/create_pr_dev_to_main.yml +1 -2
  14. {github_rest_api-0.38.0/github_rest_api/actions → github_rest_api-0.39.0/github_rest_api/scripts}/github/workflows/create_pr_to_dev.yml +1 -2
  15. {github_rest_api-0.38.0/.github → github_rest_api-0.39.0/github_rest_api/scripts/github}/workflows/create_pr_to_main.yml +1 -2
  16. {github_rest_api-0.38.0/github_rest_api/actions → github_rest_api-0.39.0/github_rest_api/scripts}/github/workflows/remove_branch.yml +1 -2
  17. {github_rest_api-0.38.0/github_rest_api/actions → github_rest_api-0.39.0/github_rest_api/scripts}/utils.py +68 -1
  18. {github_rest_api-0.38.0 → github_rest_api-0.39.0}/pyproject.toml +8 -8
  19. {github_rest_api-0.38.0 → github_rest_api-0.39.0}/tests/test_build_container_images.py +6 -6
  20. github_rest_api-0.39.0/tests/test_release_on_github.py +62 -0
  21. {github_rest_api-0.38.0 → github_rest_api-0.39.0}/uv.lock +1 -1
  22. {github_rest_api-0.38.0 → github_rest_api-0.39.0}/.gemini/system.md +0 -0
  23. {github_rest_api-0.38.0 → github_rest_api-0.39.0}/.github/workflows/lint.yml +0 -0
  24. {github_rest_api-0.38.0 → github_rest_api-0.39.0}/.github/workflows/release.yml +0 -0
  25. {github_rest_api-0.38.0 → github_rest_api-0.39.0}/.github/workflows/test.yml +0 -0
  26. {github_rest_api-0.38.0 → github_rest_api-0.39.0}/.gitignore +0 -0
  27. {github_rest_api-0.38.0 → github_rest_api-0.39.0}/.gitpod.yml +0 -0
  28. {github_rest_api-0.38.0 → github_rest_api-0.39.0}/README.md +0 -0
  29. {github_rest_api-0.38.0 → github_rest_api-0.39.0}/github_rest_api/__init__.py +0 -0
  30. {github_rest_api-0.38.0 → github_rest_api-0.39.0}/github_rest_api/github.py +0 -0
  31. {github_rest_api-0.38.0/github_rest_api/actions → github_rest_api-0.39.0/github_rest_api/scripts}/__init__.py +0 -0
  32. {github_rest_api-0.38.0/github_rest_api/actions → github_rest_api-0.39.0/github_rest_api/scripts}/cargo/__init__.py +0 -0
  33. {github_rest_api-0.38.0/github_rest_api/actions → github_rest_api-0.39.0/github_rest_api/scripts}/cargo/benchmark.py +0 -0
  34. {github_rest_api-0.38.0/github_rest_api/actions → github_rest_api-0.39.0/github_rest_api/scripts}/cargo/profiling.py +0 -0
  35. {github_rest_api-0.38.0/github_rest_api/actions → github_rest_api-0.39.0/github_rest_api/scripts}/cargo/utils.py +0 -0
  36. {github_rest_api-0.38.0/github_rest_api/actions → github_rest_api-0.39.0/github_rest_api/scripts}/container/__init__.py +0 -0
  37. {github_rest_api-0.38.0/github_rest_api/actions → github_rest_api-0.39.0/github_rest_api/scripts}/github/__init__.py +0 -0
  38. {github_rest_api-0.38.0/github_rest_api/actions → github_rest_api-0.39.0/github_rest_api/scripts}/github/workflows/python/lint.yml +0 -0
  39. {github_rest_api-0.38.0/github_rest_api/actions → github_rest_api-0.39.0/github_rest_api/scripts}/github/workflows/python/test.yml +0 -0
  40. {github_rest_api-0.38.0 → github_rest_api-0.39.0}/github_rest_api/utils.py +0 -0
  41. {github_rest_api-0.38.0 → github_rest_api-0.39.0}/memory/MEMORY.md +0 -0
  42. {github_rest_api-0.38.0 → github_rest_api-0.39.0}/memory/feedback_test_runner.md +0 -0
  43. {github_rest_api-0.38.0 → github_rest_api-0.39.0}/tests/__init__.py +0 -0
  44. {github_rest_api-0.38.0 → github_rest_api-0.39.0}/tests/test_github.py +0 -0
  45. {github_rest_api-0.38.0 → github_rest_api-0.39.0}/tests/test_utils.py +0 -0
@@ -0,0 +1,9 @@
1
+ {
2
+ "permissions": {
3
+ "allow": [
4
+ "Bash(jj log *)",
5
+ "Bash(jj bookmark list *)",
6
+ "Bash(jj diff *)"
7
+ ]
8
+ }
9
+ }
@@ -13,8 +13,7 @@ jobs:
13
13
  curl -LsSf https://astral.sh/uv/install.sh | sudo env UV_INSTALL_DIR="/usr/local/bin" sh
14
14
  - name: Create PR To main
15
15
  run: |
16
- curl -sSL https://raw.githubusercontent.com/legendu-net/github_actions_scripts/main/create_pull_request.py \
17
- | uv run --script - \
16
+ uvx --from github-rest-api@latest create_pull_request \
18
17
  --head-branch ${{ github.ref_name }} \
19
18
  --base-branch main \
20
19
  --token ${{ secrets.GITHUBACTIONS }}
@@ -12,8 +12,7 @@ jobs:
12
12
  curl -LsSf https://astral.sh/uv/install.sh | sudo env UV_INSTALL_DIR="/usr/local/bin" sh
13
13
  - name: Remove Branches
14
14
  run: |
15
- curl -sSL https://raw.githubusercontent.com/legendu-net/github_actions_scripts/main/remove_branch.py \
16
- | uv run --script - \
15
+ uvx --from github-rest-api@latest remove_branch \
17
16
  --repo ${{ github.repository }} \
18
17
  --pattern '^(?!main$)' \
19
18
  --token ${{ secrets.GITHUBACTIONS }}
@@ -15,9 +15,9 @@ A simple Python wrapper for GitHub REST APIs, optimized for use in GitHub Action
15
15
  - **psutil**: For system and process utilities.
16
16
  - **Architecture:**
17
17
  - `github_rest_api/github.py`: Contains the `GitHub` class for handling API requests (GET, POST, DELETE, PUT, PATCH).
18
- - `github_rest_api/actions/github/`: Utilities for GitHub actions like creating pull requests, managing releases, and adding repositories.
19
- - `github_rest_api/actions/container/`: Utilities for building and configuring container images.
20
- - `github_rest_api/actions/cargo/`: Specific support for Rust projects (benchmarking and profiling).
18
+ - `github_rest_api/scripts/github/`: Utilities for GitHub actions like creating pull requests, managing releases, and adding repositories.
19
+ - `github_rest_api/scripts/container/`: Utilities for building and configuring container images.
20
+ - `github_rest_api/scripts/cargo/`: Specific support for Rust projects (benchmarking and profiling).
21
21
  - `github_rest_api/utils.py`: General-purpose utilities (versioning, partitioning).
22
22
 
23
23
  ## Building and Running
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: github-rest-api
3
- Version: 0.38.0
3
+ Version: 0.39.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
@@ -132,6 +132,14 @@ def _build_image(
132
132
  _push_image(f"{image}:{tag}", tool=tool)
133
133
 
134
134
 
135
+ def _validate_paths_exist(paths: Sequence[str], label: str) -> None:
136
+ missing = [p for p in paths if not Path(p).exists()]
137
+ if missing:
138
+ raise FileNotFoundError(
139
+ f"\nError: the following {label} do not exist:\n{'\n'.join(missing)}"
140
+ )
141
+
142
+
135
143
  def build_images(
136
144
  commit1: str,
137
145
  commit2: str,
@@ -140,6 +148,8 @@ def build_images(
140
148
  tool: str = "podman",
141
149
  registry: str = "quay.io/legendu",
142
150
  ):
151
+ _validate_paths_exist(image_dirs, "image dirs")
152
+ _validate_paths_exist(paths_monitoring, "monitored paths")
143
153
  if not has_relevant_changes(commit1, commit2, image_dirs, paths_monitoring):
144
154
  print(
145
155
  f"Skip building {tool} images as there are no relevant changes between {
@@ -160,7 +170,9 @@ def build_images(
160
170
  print(f"Error building {image_dir}: {e}", flush=True)
161
171
  failures.append(image_dir)
162
172
  if failures:
163
- sys.exit(f"\n\nError: failed to build images: {', '.join(failures)}\n")
173
+ raise RuntimeError(
174
+ f"\n\nError: failed to build images: {', '.join(failures)}\n"
175
+ )
164
176
 
165
177
 
166
178
  def parse_args():
@@ -209,11 +221,11 @@ def parse_args():
209
221
  help="Explicit list of image directories to build.",
210
222
  )
211
223
  group.add_argument(
212
- "-f",
224
+ "-y",
213
225
  "--yaml-image-dirs",
214
226
  dest="yaml_image_dirs",
215
227
  default=None,
216
- metavar="FILE",
228
+ metavar="YAML_FILE",
217
229
  help="Path to a YAML file containing a list of image dirs to build.",
218
230
  )
219
231
  parser.add_argument(
@@ -243,17 +255,22 @@ def _resolve_image_dirs(args: argparse.Namespace) -> list[str]:
243
255
  return [s for item in data if (s := item.strip())]
244
256
 
245
257
 
246
- def main():
258
+ def main() -> int:
247
259
  args = parse_args()
248
- build_images(
249
- args.commit1,
250
- args.commit2,
251
- _resolve_image_dirs(args),
252
- paths_monitoring=args.paths_monitoring,
253
- tool=args.tool,
254
- registry=args.registry,
255
- )
260
+ try:
261
+ build_images(
262
+ args.commit1,
263
+ args.commit2,
264
+ _resolve_image_dirs(args),
265
+ paths_monitoring=args.paths_monitoring,
266
+ tool=args.tool,
267
+ registry=args.registry,
268
+ )
269
+ except Exception as e:
270
+ print(str(e), file=sys.stderr)
271
+ return 1
272
+ return 0
256
273
 
257
274
 
258
275
  if __name__ == "__main__":
259
- main()
276
+ sys.exit(main())
@@ -1,6 +1,7 @@
1
1
  import argparse
2
2
  import json
3
3
  import shutil
4
+ import sys
4
5
  import tomllib
5
6
  import tomli_w
6
7
  from pathlib import Path
@@ -69,14 +70,19 @@ def parse_args(args=None):
69
70
  return parser.parse_args(args=args)
70
71
 
71
72
 
72
- def main():
73
+ def main() -> int:
73
74
  args = parse_args()
74
75
  runtimes = set(args.runtime) if args.runtime else {"docker", "podman"}
75
- if "docker" in runtimes:
76
- config_docker(args.docker_data_root)
77
- if "podman" in runtimes:
78
- config_podman(args.podman_graphroot)
76
+ try:
77
+ if "docker" in runtimes:
78
+ config_docker(args.docker_data_root)
79
+ if "podman" in runtimes:
80
+ config_podman(args.podman_graphroot)
81
+ except Exception as e:
82
+ print(f"Error: {e}", file=sys.stderr)
83
+ return 1
84
+ return 0
79
85
 
80
86
 
81
87
  if __name__ == "__main__":
82
- main()
88
+ sys.exit(main())
@@ -1,6 +1,7 @@
1
1
  import argparse
2
2
  import datetime
3
3
  import os
4
+ import sys
4
5
  from pathlib import Path
5
6
  import re
6
7
  from dulwich import porcelain
@@ -124,22 +125,27 @@ def checkout_branch(repo: str):
124
125
  porcelain.checkout(repo=".", target=branch)
125
126
 
126
127
 
127
- def main():
128
+ def main() -> int:
128
129
  args = parse_args()
129
- if has_open_pr(head_prefix=_branch_prefix(args.repo)):
130
- return
131
- checkout_branch(args.repo)
132
- version = parse_latest_version(repo=args.repo)
133
- if args.next_minor_or_strip_patch is not None:
134
- version = next_minor_or_strip_patch(version, args.next_minor_or_strip_patch)
135
- update_version(
136
- containerfile=args.containerfile,
137
- version=version,
138
- pattern=args.pattern,
139
- replace=args.replace,
140
- )
141
- push_changes(repo=args.repo, token=args.token)
130
+ try:
131
+ if has_open_pr(head_prefix=_branch_prefix(args.repo)):
132
+ return 0
133
+ checkout_branch(args.repo)
134
+ version = parse_latest_version(repo=args.repo)
135
+ if args.next_minor_or_strip_patch is not None:
136
+ version = next_minor_or_strip_patch(version, args.next_minor_or_strip_patch)
137
+ update_version(
138
+ containerfile=args.containerfile,
139
+ version=version,
140
+ pattern=args.pattern,
141
+ replace=args.replace,
142
+ )
143
+ push_changes(repo=args.repo, token=args.token)
144
+ except Exception as e:
145
+ print(str(e), file=sys.stderr)
146
+ return 1
147
+ return 0
142
148
 
143
149
 
144
150
  if __name__ == "__main__":
145
- main()
151
+ sys.exit(main())
@@ -77,7 +77,7 @@ def parse_args(args=None, namespace=None):
77
77
  def _validate_repo(repo: str) -> None:
78
78
  parts = repo.split("/")
79
79
  if len(parts) != 2 or not parts[0] or not parts[1]:
80
- sys.exit(f"Invalid repo format '{repo}'. Expected 'owner/repo'.")
80
+ raise ValueError(f"Invalid repo format '{repo}'. Expected 'owner/repo'.")
81
81
 
82
82
 
83
83
  def _create_remote_repo(
@@ -142,7 +142,7 @@ def add_github_repo(
142
142
  if not token:
143
143
  token = getpass.getpass("Please enter your GitHub token: ")
144
144
  if not token:
145
- sys.exit(
145
+ raise ValueError(
146
146
  "No GitHub token is provided (via $GITHUB_TOKEN, --token or at prompt)."
147
147
  )
148
148
  repo = repo.strip()
@@ -177,18 +177,23 @@ def _add_workflow(path: Path, language: str, workflow_dir: Path | None = None) -
177
177
  shutil.copy2(yaml, dir_dest)
178
178
 
179
179
 
180
- def main():
180
+ def main() -> int:
181
181
  args = parse_args()
182
- add_github_repo(
183
- repo=args.repo,
184
- private=args.private,
185
- language=args.language,
186
- is_owner_user=args.is_owner_user,
187
- dir_=args.dir,
188
- token=args.token,
189
- branches=args.branches,
190
- )
182
+ try:
183
+ add_github_repo(
184
+ repo=args.repo,
185
+ private=args.private,
186
+ language=args.language,
187
+ is_owner_user=args.is_owner_user,
188
+ dir_=args.dir,
189
+ token=args.token,
190
+ branches=args.branches,
191
+ )
192
+ except Exception as e:
193
+ print(str(e), file=sys.stderr)
194
+ return 1
195
+ return 0
191
196
 
192
197
 
193
198
  if __name__ == "__main__":
194
- main()
199
+ sys.exit(main())
@@ -4,6 +4,7 @@ The branch is updated (using dev) before creating the PR.
4
4
 
5
5
  from argparse import ArgumentParser, Namespace
6
6
  import os
7
+ import sys
7
8
  from github_rest_api import Repository
8
9
 
9
10
 
@@ -36,7 +37,7 @@ def parse_args(args=None, namespace=None) -> Namespace:
36
37
  return parser.parse_args(args=args, namespace=namespace)
37
38
 
38
39
 
39
- def main():
40
+ def main() -> int:
40
41
  """Main entrance of the script,
41
42
  which creates a PR from the specified branch to dev.
42
43
  The branch is updated (using dev) before creating the PR.
@@ -44,7 +45,7 @@ def main():
44
45
  args = parse_args()
45
46
  # skip branches with the pattern _*
46
47
  if args.head_branch.startswith("_"):
47
- return
48
+ return 0
48
49
  repo = Repository(args.token, os.environ["GITHUB_REPOSITORY"])
49
50
  repo.create_pull_request(
50
51
  {
@@ -53,7 +54,8 @@ def main():
53
54
  "title": f"Merge {args.head_branch} Into {args.base_branch}",
54
55
  },
55
56
  )
57
+ return 0
56
58
 
57
59
 
58
60
  if __name__ == "__main__":
59
- main()
61
+ sys.exit(main())
@@ -1,93 +1,44 @@
1
1
  import os
2
+ import re
2
3
  import sys
3
4
  import argparse
4
- import tomllib
5
5
  import getpass
6
6
  from pathlib import Path
7
- from dulwich.repo import Repo
8
- from dulwich.errors import NotGitRepository
9
7
  from github_rest_api import Repository
8
+ from github_rest_api.scripts.utils import (
9
+ find_project_root,
10
+ get_project_version,
11
+ get_repo,
12
+ )
10
13
 
11
14
 
12
- def find_root(path: Path | None = None) -> Path | None:
13
- if path is None:
14
- path = Path.cwd()
15
- while path != path.parent:
16
- if (path / ".git").exists():
17
- return path
18
- path = path.parent
19
- return None
20
-
21
-
22
- def get_version(root: Path) -> str:
23
- pyproject = root / "pyproject.toml"
24
- if pyproject.exists():
25
- with pyproject.open("rb") as f:
26
- data = tomllib.load(f)
27
- return data.get("project", {}).get("version", "")
28
- cargo = root / "Cargo.toml"
29
- if cargo.exists():
30
- with cargo.open("rb") as f:
31
- data = tomllib.load(f)
32
- return data.get("package", {}).get("version", "")
33
- return ""
34
-
35
-
36
- def get_repo(root: Path) -> str | None:
37
- pyproject = root / "pyproject.toml"
38
- if pyproject.exists():
39
- with pyproject.open("rb") as f:
40
- data = tomllib.load(f)
41
- repo_url = data.get("project", {}).get("urls", {}).get("Repository")
42
- if repo_url:
43
- return (
44
- repo_url.split("github.com/")[-1].removesuffix(".git").rstrip("/")
45
- )
46
- # Fallback to git remote
47
- try:
48
- repo = Repo(root)
49
- config = repo.get_config()
50
- output = config.get((b"remote", b"origin"), b"url").decode().strip()
51
- if "github.com" in output:
52
- if output.startswith("git@"):
53
- return output.split("github.com:")[-1].replace(".git", "").rstrip("/")
54
- return output.split("github.com/")[-1].replace(".git", "").rstrip("/")
55
- except (NotGitRepository, KeyError):
56
- pass
57
- return None
15
+ def _get_release_tag(tag: str, root: Path, validate: bool = True) -> str:
16
+ tag = tag.strip()
17
+ if not tag:
18
+ tag = get_project_version(root).strip()
19
+ if not tag:
20
+ raise ValueError(
21
+ "Could not find project version to use as tag. Please specify a tag."
22
+ )
58
23
 
24
+ if not validate:
25
+ return tag
59
26
 
60
- def parse_args(args=None, namespace=None):
61
- parser = argparse.ArgumentParser(
62
- description="Make a release of the project on GitHub."
63
- )
64
- parser.add_argument(
65
- "-b",
66
- "--branch",
67
- default="main",
68
- help="The branch (default to main) from which to make the release.",
69
- )
70
- parser.add_argument(
71
- "-t",
72
- "--tag",
73
- default="",
74
- help="The tag for the release. If not specified, the version from project configuration is used.",
75
- )
76
- parser.add_argument(
77
- "-n",
78
- "--notes",
79
- default="",
80
- help="Notes for the release. If not specified, it's auto generated.",
81
- )
82
- parser.add_argument(
83
- "--token",
84
- default="",
85
- help="GitHub token. If not specified, the GITHUB_TOKEN environment variable is used.",
86
- )
87
- return parser.parse_args(args=args, namespace=namespace)
27
+ # matches v?X.Y.Z and optionally pre-releases (e.g., v1.2.3-alpha.1) or build metadata
28
+ semver_pattern = r"v?\d+\.\d+\.\d+(?:-[a-zA-Z0-9.-]+)?(?:\+[a-zA-Z0-9.-]+)?"
29
+ if not re.fullmatch(semver_pattern, tag):
30
+ raise ValueError(
31
+ f"Tag '{tag}' is not in a semantic versioning format. Use --no-validate to skip."
32
+ )
33
+ normalized = tag if tag.startswith("v") else f"v{tag}"
34
+ if normalized != tag:
35
+ print(f"Tag normalized from '{tag}' to '{normalized}'.")
36
+ return normalized
88
37
 
89
38
 
90
- def release_on_github(token: str, branch: str, tag: str = "", notes: str = "") -> None:
39
+ def release_on_github(
40
+ token: str, branch: str, tag: str = "", notes: str = "", validate: bool = True
41
+ ) -> None:
91
42
  """Make a release of the project on GitHub.
92
43
 
93
44
  The command should be run under the root or a sub-dir of the project.
@@ -99,28 +50,24 @@ def release_on_github(token: str, branch: str, tag: str = "", notes: str = "") -
99
50
  If not specified, it's auto generated.
100
51
  :param token: GitHub token.
101
52
  If not specified, the GITHUB_TOKEN environment variable is used.
53
+ :param validate: If True, validate the tag against semantic versioning format before creating the release.
102
54
  """
103
55
  token = token or os.getenv("GITHUB_TOKEN", "")
104
56
  if not token:
105
57
  token = getpass.getpass("Please enter your GitHub token: ")
106
58
  if not token:
107
- sys.exit(
59
+ raise ValueError(
108
60
  "No GitHub token is provided (via $GITHUB_TOKEN, --token or at prompt)."
109
61
  )
110
- root = find_root()
62
+ root = find_project_root()
111
63
  if not root:
112
- sys.exit("Could not find project root (no .git found).")
64
+ raise FileNotFoundError("Could not find project root (no .git found).")
113
65
 
114
- if not tag:
115
- tag = get_version(root)
116
- if not tag:
117
- sys.exit(
118
- "Could not find project version to use as tag. Please specify a tag."
119
- )
66
+ tag = _get_release_tag(tag, root, validate=validate)
120
67
 
121
68
  repo_name = get_repo(root)
122
69
  if not repo_name:
123
- sys.exit("Could not find GitHub repository name.")
70
+ raise ValueError("Could not find GitHub repository name.")
124
71
 
125
72
  repo = Repository(token=token, repo=repo_name)
126
73
  data = {
@@ -136,12 +83,57 @@ def release_on_github(token: str, branch: str, tag: str = "", notes: str = "") -
136
83
  print(f"Successfully created release {tag} on {repo_name}.")
137
84
 
138
85
 
139
- def main():
140
- args = parse_args()
141
- release_on_github(
142
- token=args.token, branch=args.branch, tag=args.tag, notes=args.notes
86
+ def parse_args(args=None, namespace=None):
87
+ parser = argparse.ArgumentParser(
88
+ description="Make a release of the project on GitHub."
89
+ )
90
+ parser.add_argument(
91
+ "-b",
92
+ "--branch",
93
+ default="main",
94
+ help="The branch (default to main) from which to make the release.",
143
95
  )
96
+ parser.add_argument(
97
+ "-t",
98
+ "--tag",
99
+ default="",
100
+ help="The tag for the release. If not specified, the version from project configuration is used.",
101
+ )
102
+ parser.add_argument(
103
+ "-n",
104
+ "--notes",
105
+ default="",
106
+ help="Notes for the release. If not specified, it's auto generated.",
107
+ )
108
+ parser.add_argument(
109
+ "--token",
110
+ default="",
111
+ help="GitHub token. If not specified, the GITHUB_TOKEN environment variable is used.",
112
+ )
113
+ parser.add_argument(
114
+ "--no-validate",
115
+ dest="validate",
116
+ action="store_false",
117
+ help="Skip release tag format validation and normalization. Use it exactly as provided.",
118
+ )
119
+ return parser.parse_args(args=args, namespace=namespace)
120
+
121
+
122
+ def main() -> int:
123
+ args = parse_args()
124
+ try:
125
+ release_on_github(
126
+ token=args.token,
127
+ branch=args.branch,
128
+ tag=args.tag,
129
+ notes=args.notes,
130
+ validate=args.validate,
131
+ )
132
+ except Exception as e:
133
+ print(str(e), file=sys.stderr)
134
+ return 1
135
+ return 0
144
136
 
145
137
 
146
138
  if __name__ == "__main__":
147
- main()
139
+ sys.exit(main())
@@ -1,5 +1,6 @@
1
1
  import argparse
2
2
  import re
3
+ import sys
3
4
  import datetime
4
5
  from github_rest_api import Repository
5
6
 
@@ -93,10 +94,15 @@ def remove_branch(token: str, repo: str, pattern: str) -> None:
93
94
  repository.delete_branch(branch_name)
94
95
 
95
96
 
96
- def main():
97
+ def main() -> int:
97
98
  args = parse_args()
98
- remove_branch(token=args.token, repo=args.repo, pattern=args.pattern)
99
+ try:
100
+ remove_branch(token=args.token, repo=args.repo, pattern=args.pattern)
101
+ except Exception as e:
102
+ print(str(e), file=sys.stderr)
103
+ return 1
104
+ return 0
99
105
 
100
106
 
101
107
  if __name__ == "__main__":
102
- main()
108
+ sys.exit(main())
@@ -12,8 +12,7 @@ jobs:
12
12
  curl -LsSf https://astral.sh/uv/install.sh | sudo env UV_INSTALL_DIR="/usr/local/bin" sh
13
13
  - name: Create PR From dev To main
14
14
  run: |
15
- curl -sSL https://raw.githubusercontent.com/legendu-net/github_actions_scripts/main/create_pull_request.py \
16
- | uv run --script - \
15
+ uvx --from github-rest-api@latest create_pull_request \
17
16
  --head-branch dev \
18
17
  --base-branch main \
19
18
  --token ${{ secrets.GITHUBACTIONS }}
@@ -14,8 +14,7 @@ jobs:
14
14
  curl -LsSf https://astral.sh/uv/install.sh | sudo env UV_INSTALL_DIR="/usr/local/bin" sh
15
15
  - name: Create PR to dev
16
16
  run: |
17
- curl -sSL https://raw.githubusercontent.com/legendu-net/github_actions_scripts/main/create_pull_request.py \
18
- | uv run --script - \
17
+ uvx --from github-rest-api@latest create_pull_request \
19
18
  --head-branch ${{ github.ref_name }} \
20
19
  --base-branch dev \
21
20
  --token ${{ secrets.GITHUBACTIONS }}
@@ -13,8 +13,7 @@ jobs:
13
13
  curl -LsSf https://astral.sh/uv/install.sh | sudo env UV_INSTALL_DIR="/usr/local/bin" sh
14
14
  - name: Create PR To main
15
15
  run: |
16
- curl -sSL https://raw.githubusercontent.com/legendu-net/github_actions_scripts/main/create_pull_request.py \
17
- | uv run --script - \
16
+ uvx --from github-rest-api@latest create_pull_request \
18
17
  --head-branch ${{ github.ref_name }} \
19
18
  --base-branch main \
20
19
  --token ${{ secrets.GITHUBACTIONS }}
@@ -12,8 +12,7 @@ jobs:
12
12
  curl -LsSf https://astral.sh/uv/install.sh | sudo env UV_INSTALL_DIR="/usr/local/bin" sh
13
13
  - name: Remove Branches
14
14
  run: |
15
- curl -sSL https://raw.githubusercontent.com/legendu-net/github_actions_scripts/main/remove_branch.py \
16
- | uv run --script - \
15
+ uvx --from github-rest-api@latest remove_branch \
17
16
  --repo ${{ github.repository }} \
18
17
  --pattern '^(?!(main|dev)$)' \
19
18
  --token ${{ secrets.GITHUBACTIONS }}
@@ -1,7 +1,9 @@
1
1
  """Util functions for GitHub actions."""
2
2
 
3
- from typing import Iterable
3
+ import tomllib
4
+ from typing import Any, Iterable
4
5
  from pathlib import Path
6
+ from collections.abc import Sequence
5
7
  import random
6
8
  from dulwich import porcelain
7
9
  from dulwich.repo import Repo
@@ -73,3 +75,68 @@ def commit_profiling(prof_dir: str | Path):
73
75
  """
74
76
  porcelain.add(paths=prof_dir)
75
77
  porcelain.commit(message="Updating profiling results.")
78
+
79
+
80
+ def find_project_root(path: Path | None = None) -> Path | None:
81
+ if path is None:
82
+ path = Path.cwd()
83
+ while path != path.parent:
84
+ if (path / ".git").exists():
85
+ return path
86
+ path = path.parent
87
+ return None
88
+
89
+
90
+ def get_toml_value(path: Path, keys: Sequence[str]) -> Any:
91
+ if not keys or not path.exists():
92
+ return None
93
+ try:
94
+ with path.open("rb") as f:
95
+ data = tomllib.load(f)
96
+ except Exception:
97
+ return None
98
+ for key in keys[:-1]:
99
+ data = data.get(key, {}) if isinstance(data, dict) else {}
100
+ return data.get(keys[-1]) if isinstance(data, dict) else None
101
+
102
+
103
+ def get_project_version(root: Path) -> str:
104
+ for path, keys in [
105
+ (root / "pyproject.toml", ["project", "version"]),
106
+ (root / "Cargo.toml", ["package", "version"]),
107
+ ]:
108
+ version = get_toml_value(path, keys)
109
+ if isinstance(version, str) and version.strip():
110
+ return version
111
+ return ""
112
+
113
+
114
+ def parse_github_repo(url: str) -> str:
115
+ delimiters = ["github.com/", "github.com:"]
116
+ delim = next((d for d in delimiters if d in url), "")
117
+ if not delim:
118
+ return ""
119
+ return url.split(delim)[-1].rstrip("/").removesuffix(".git")
120
+
121
+
122
+ def _get_repo_from_toml(path: Path, keys: Sequence[str]) -> str:
123
+ value = get_toml_value(path, keys)
124
+ return parse_github_repo(value) if isinstance(value, str) else ""
125
+
126
+
127
+ def get_repo(root: Path) -> str | None:
128
+ if repo := _get_repo_from_toml(
129
+ root / "pyproject.toml", ["project", "urls", "Repository"]
130
+ ):
131
+ return repo
132
+ if repo := _get_repo_from_toml(root / "Cargo.toml", ["package", "repository"]):
133
+ return repo
134
+ try:
135
+ repo = Repo(root)
136
+ config = repo.get_config()
137
+ url = config.get((b"remote", b"origin"), b"url").decode().strip()
138
+ if repo_name := parse_github_repo(url):
139
+ return repo_name
140
+ except Exception:
141
+ pass
142
+ return None
@@ -4,7 +4,7 @@ requires = [ "hatchling" ]
4
4
 
5
5
  [project]
6
6
  name = "github-rest-api"
7
- version = "0.38.0"
7
+ version = "0.39.0"
8
8
  description = "Simple wrapper of GitHub REST APIs."
9
9
  readme = "README.md"
10
10
  authors = [ { name = "Ben Du", email = "longendu@yahoo.com" } ]
@@ -23,13 +23,13 @@ dependencies = [
23
23
  "tenacity>=9.1.4",
24
24
  "tomli-w>=1",
25
25
  ]
26
- scripts.add_github_repo = "github_rest_api.actions.github.add_github_repo:main"
27
- scripts.build_container_images = "github_rest_api.actions.container.build_container_images:main"
28
- scripts.config_container = "github_rest_api.actions.container.config_container:main"
29
- scripts.create_pull_request = "github_rest_api.actions.github.create_pull_request:main"
30
- scripts.release_on_github = "github_rest_api.actions.github.release_on_github:main"
31
- scripts.remove_branch = "github_rest_api.actions.github.remove_branch:main"
32
- scripts.update_version_containerfile = "github_rest_api.actions.container.update_version_containerfile:main"
26
+ scripts.add_github_repo = "github_rest_api.scripts.github.add_github_repo:main"
27
+ scripts.build_container_images = "github_rest_api.scripts.container.build_container_images:main"
28
+ scripts.config_container = "github_rest_api.scripts.container.config_container:main"
29
+ scripts.create_pull_request = "github_rest_api.scripts.github.create_pull_request:main"
30
+ scripts.release_on_github = "github_rest_api.scripts.github.release_on_github:main"
31
+ scripts.remove_branch = "github_rest_api.scripts.github.remove_branch:main"
32
+ scripts.update_version_containerfile = "github_rest_api.scripts.container.update_version_containerfile:main"
33
33
 
34
34
  [dependency-groups]
35
35
  dev = [
@@ -1,6 +1,6 @@
1
1
  from pathlib import Path
2
2
  from unittest.mock import patch
3
- from github_rest_api.actions.container.build_container_images import (
3
+ from github_rest_api.scripts.container.build_container_images import (
4
4
  has_relevant_changes,
5
5
  )
6
6
 
@@ -19,7 +19,7 @@ def test_has_relevant_changes_empty_commits():
19
19
 
20
20
 
21
21
  @patch(
22
- "github_rest_api.actions.container.build_container_images.changed_files_between",
22
+ "github_rest_api.scripts.container.build_container_images.changed_files_between",
23
23
  return_value=[],
24
24
  )
25
25
  def test_has_relevant_changes_no_changes(mock_changed):
@@ -28,7 +28,7 @@ def test_has_relevant_changes_no_changes(mock_changed):
28
28
 
29
29
 
30
30
  @patch(
31
- "github_rest_api.actions.container.build_container_images.changed_files_between",
31
+ "github_rest_api.scripts.container.build_container_images.changed_files_between",
32
32
  return_value=[IRRELEVANT_FILE],
33
33
  )
34
34
  def test_has_relevant_changes_only_irrelevant_files(mock_changed):
@@ -37,7 +37,7 @@ def test_has_relevant_changes_only_irrelevant_files(mock_changed):
37
37
 
38
38
 
39
39
  @patch(
40
- "github_rest_api.actions.container.build_container_images.changed_files_between",
40
+ "github_rest_api.scripts.container.build_container_images.changed_files_between",
41
41
  return_value=[RELEVANT_FILE],
42
42
  )
43
43
  def test_has_relevant_changes_with_relevant_file(mock_changed):
@@ -46,7 +46,7 @@ def test_has_relevant_changes_with_relevant_file(mock_changed):
46
46
 
47
47
 
48
48
  @patch(
49
- "github_rest_api.actions.container.build_container_images.changed_files_between",
49
+ "github_rest_api.scripts.container.build_container_images.changed_files_between",
50
50
  return_value=[IRRELEVANT_FILE, RELEVANT_FILE],
51
51
  )
52
52
  def test_has_relevant_changes_mixed_files(mock_changed):
@@ -55,7 +55,7 @@ def test_has_relevant_changes_mixed_files(mock_changed):
55
55
 
56
56
 
57
57
  @patch(
58
- "github_rest_api.actions.container.build_container_images.changed_files_between",
58
+ "github_rest_api.scripts.container.build_container_images.changed_files_between",
59
59
  return_value=[RELEVANT_FILE],
60
60
  )
61
61
  def test_has_relevant_changes_bytes_commits(mock_changed):
@@ -0,0 +1,62 @@
1
+ from pathlib import Path
2
+ from unittest.mock import patch
3
+ import pytest
4
+ from github_rest_api.scripts.github.release_on_github import _get_release_tag
5
+
6
+ ROOT = Path(".")
7
+
8
+
9
+ def test_tag_already_normalized():
10
+ assert _get_release_tag("v1.2.3", ROOT) == "v1.2.3"
11
+
12
+
13
+ def test_tag_bare_gets_v_prefix(capsys):
14
+ assert _get_release_tag("1.2.3", ROOT) == "v1.2.3"
15
+ assert "normalized" in capsys.readouterr().out
16
+
17
+
18
+ def test_tag_with_surrounding_whitespace():
19
+ assert _get_release_tag(" v1.2.3 ", ROOT) == "v1.2.3"
20
+
21
+
22
+ def test_empty_tag_falls_back_to_version():
23
+ with patch(
24
+ "github_rest_api.scripts.github.release_on_github.get_project_version",
25
+ return_value="1.2.3",
26
+ ):
27
+ assert _get_release_tag("", ROOT) == "v1.2.3"
28
+
29
+
30
+ def test_whitespace_tag_falls_back_to_version():
31
+ with patch(
32
+ "github_rest_api.scripts.github.release_on_github.get_project_version",
33
+ return_value="1.2.3",
34
+ ):
35
+ assert _get_release_tag(" ", ROOT) == "v1.2.3"
36
+
37
+
38
+ def test_empty_tag_no_version_exits():
39
+ with patch(
40
+ "github_rest_api.scripts.github.release_on_github.get_project_version",
41
+ return_value="",
42
+ ):
43
+ with pytest.raises(ValueError):
44
+ _get_release_tag("", ROOT)
45
+
46
+
47
+ def test_invalid_tag_format_exits():
48
+ with pytest.raises(ValueError):
49
+ _get_release_tag("invalid", ROOT)
50
+
51
+
52
+ def test_no_validate_skips_validation_and_normalization():
53
+ assert _get_release_tag("invalid", ROOT, validate=False) == "invalid"
54
+ assert _get_release_tag("1.2.3", ROOT, validate=False) == "1.2.3"
55
+
56
+
57
+ def test_prerelease_tag():
58
+ assert _get_release_tag("v1.2.3-alpha.1", ROOT) == "v1.2.3-alpha.1"
59
+
60
+
61
+ def test_build_metadata_tag():
62
+ assert _get_release_tag("1.2.3+build", ROOT) == "v1.2.3+build"
@@ -166,7 +166,7 @@ wheels = [
166
166
 
167
167
  [[package]]
168
168
  name = "github-rest-api"
169
- version = "0.38.0"
169
+ version = "0.38.2"
170
170
  source = { editable = "." }
171
171
  dependencies = [
172
172
  { name = "dulwich" },