hugoh-repokit 0.1.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.
- hugoh_repokit-0.1.0/.gitignore +4 -0
- hugoh_repokit-0.1.0/LICENSE +21 -0
- hugoh_repokit-0.1.0/PKG-INFO +72 -0
- hugoh_repokit-0.1.0/README.md +49 -0
- hugoh_repokit-0.1.0/pyproject.toml +46 -0
- hugoh_repokit-0.1.0/src/repokit/__init__.py +39 -0
- hugoh_repokit-0.1.0/src/repokit/core.py +142 -0
- hugoh_repokit-0.1.0/src/repokit/email.py +54 -0
- hugoh_repokit-0.1.0/src/repokit/py.typed +0 -0
- hugoh_repokit-0.1.0/tests/test_core.py +148 -0
- hugoh_repokit-0.1.0/tests/test_email.py +121 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Hugo Haas
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: hugoh-repokit
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Repo listing/filtering and CLI-entrypoint plumbing shared by repo-admin's scripts
|
|
5
|
+
Project-URL: Homepage, https://github.com/hugoh/gh-workflows/tree/main/repokit
|
|
6
|
+
Project-URL: Issues, https://github.com/hugoh/gh-workflows/issues
|
|
7
|
+
Project-URL: Repository, https://github.com/hugoh/gh-workflows
|
|
8
|
+
Author: Hugo Haas
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
18
|
+
Classifier: Typing :: Typed
|
|
19
|
+
Requires-Python: >=3.11
|
|
20
|
+
Requires-Dist: asyncgh>=0.2.0
|
|
21
|
+
Requires-Dist: reconcilekit>=0.2.0
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# repokit
|
|
25
|
+
|
|
26
|
+
Repo listing/filtering and CLI-entrypoint plumbing shared by repo-admin-style
|
|
27
|
+
scripts: fetch an account's repos via [`asyncgh`](../asyncgh/README.md), keep
|
|
28
|
+
the non-archived/non-fork (by default) ones matching an `only`/`skip` filter,
|
|
29
|
+
and run work over them in bounded parallel via
|
|
30
|
+
[`reconcilekit`](../reconcilekit/README.md). No config-file loading, no sops,
|
|
31
|
+
no fork/exclude policy — those are a caller concern, which is why
|
|
32
|
+
`list_repos`'s `include_forks` defaults to none included rather than reading
|
|
33
|
+
any file.
|
|
34
|
+
|
|
35
|
+
Published to PyPI as `hugoh-repokit`; imported as `repokit`.
|
|
36
|
+
|
|
37
|
+
## API
|
|
38
|
+
|
|
39
|
+
Full API reference, generated from the docstrings:
|
|
40
|
+
[hugoh.github.io/gh-workflows/repokit](https://hugoh.github.io/gh-workflows/repokit/)
|
|
41
|
+
(rebuilt on every push that touches this package — see
|
|
42
|
+
`.github/workflows/docs.yml`).
|
|
43
|
+
|
|
44
|
+
## Example
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
import asyncio
|
|
48
|
+
|
|
49
|
+
from repokit import DEFAULT_OWNER, list_repos, run_cli
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
async def main(args) -> int:
|
|
53
|
+
repos = await list_repos(DEFAULT_OWNER, only=args.only, skip=args.skip)
|
|
54
|
+
for repo in repos:
|
|
55
|
+
print(repo.name)
|
|
56
|
+
return 0
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def cli(argv: list[str]) -> int:
|
|
60
|
+
return run_cli(main, parse_args(argv))
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Consumers
|
|
64
|
+
|
|
65
|
+
`repo-admin/` (in this repo) uses it for `repo_admin.py`/`activity.py`,
|
|
66
|
+
consumed as a uv workspace member there — `repo-admin/lib.py` layers its
|
|
67
|
+
own config-file-backed fork/exclude policy, sops-based secrets, and
|
|
68
|
+
`console`/`progress_bar`/etc. re-exports from `asyncgh`/`reconcilekit` on
|
|
69
|
+
top, kept local since none of that is generic.
|
|
70
|
+
[`digest-action`](https://github.com/hugoh/digest-action) (a separate repo,
|
|
71
|
+
since GitHub Marketplace only publishes actions from a repository root)
|
|
72
|
+
consumes it as a regular PyPI dependency.
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# repokit
|
|
2
|
+
|
|
3
|
+
Repo listing/filtering and CLI-entrypoint plumbing shared by repo-admin-style
|
|
4
|
+
scripts: fetch an account's repos via [`asyncgh`](../asyncgh/README.md), keep
|
|
5
|
+
the non-archived/non-fork (by default) ones matching an `only`/`skip` filter,
|
|
6
|
+
and run work over them in bounded parallel via
|
|
7
|
+
[`reconcilekit`](../reconcilekit/README.md). No config-file loading, no sops,
|
|
8
|
+
no fork/exclude policy — those are a caller concern, which is why
|
|
9
|
+
`list_repos`'s `include_forks` defaults to none included rather than reading
|
|
10
|
+
any file.
|
|
11
|
+
|
|
12
|
+
Published to PyPI as `hugoh-repokit`; imported as `repokit`.
|
|
13
|
+
|
|
14
|
+
## API
|
|
15
|
+
|
|
16
|
+
Full API reference, generated from the docstrings:
|
|
17
|
+
[hugoh.github.io/gh-workflows/repokit](https://hugoh.github.io/gh-workflows/repokit/)
|
|
18
|
+
(rebuilt on every push that touches this package — see
|
|
19
|
+
`.github/workflows/docs.yml`).
|
|
20
|
+
|
|
21
|
+
## Example
|
|
22
|
+
|
|
23
|
+
```python
|
|
24
|
+
import asyncio
|
|
25
|
+
|
|
26
|
+
from repokit import DEFAULT_OWNER, list_repos, run_cli
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
async def main(args) -> int:
|
|
30
|
+
repos = await list_repos(DEFAULT_OWNER, only=args.only, skip=args.skip)
|
|
31
|
+
for repo in repos:
|
|
32
|
+
print(repo.name)
|
|
33
|
+
return 0
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def cli(argv: list[str]) -> int:
|
|
37
|
+
return run_cli(main, parse_args(argv))
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Consumers
|
|
41
|
+
|
|
42
|
+
`repo-admin/` (in this repo) uses it for `repo_admin.py`/`activity.py`,
|
|
43
|
+
consumed as a uv workspace member there — `repo-admin/lib.py` layers its
|
|
44
|
+
own config-file-backed fork/exclude policy, sops-based secrets, and
|
|
45
|
+
`console`/`progress_bar`/etc. re-exports from `asyncgh`/`reconcilekit` on
|
|
46
|
+
top, kept local since none of that is generic.
|
|
47
|
+
[`digest-action`](https://github.com/hugoh/digest-action) (a separate repo,
|
|
48
|
+
since GitHub Marketplace only publishes actions from a repository root)
|
|
49
|
+
consumes it as a regular PyPI dependency.
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "hugoh-repokit"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Repo listing/filtering and CLI-entrypoint plumbing shared by repo-admin's scripts"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.11"
|
|
7
|
+
license = "MIT"
|
|
8
|
+
license-files = ["LICENSE"]
|
|
9
|
+
authors = [
|
|
10
|
+
{ name = "Hugo Haas" },
|
|
11
|
+
]
|
|
12
|
+
classifiers = [
|
|
13
|
+
"Development Status :: 4 - Beta",
|
|
14
|
+
"Intended Audience :: Developers",
|
|
15
|
+
"Operating System :: OS Independent",
|
|
16
|
+
"Programming Language :: Python :: 3.11",
|
|
17
|
+
"Programming Language :: Python :: 3.12",
|
|
18
|
+
"Programming Language :: Python :: 3.13",
|
|
19
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
20
|
+
"Typing :: Typed",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
dependencies = ["asyncgh>=0.2.0", "reconcilekit>=0.2.0"]
|
|
24
|
+
|
|
25
|
+
[project.urls]
|
|
26
|
+
Homepage = "https://github.com/hugoh/gh-workflows/tree/main/repokit"
|
|
27
|
+
Issues = "https://github.com/hugoh/gh-workflows/issues"
|
|
28
|
+
Repository = "https://github.com/hugoh/gh-workflows"
|
|
29
|
+
|
|
30
|
+
[dependency-groups]
|
|
31
|
+
dev = ["pytest>=8", "pytest-asyncio>=0.24"]
|
|
32
|
+
|
|
33
|
+
[build-system]
|
|
34
|
+
requires = ["hatchling"]
|
|
35
|
+
build-backend = "hatchling.build"
|
|
36
|
+
|
|
37
|
+
[tool.hatch.build.targets.wheel]
|
|
38
|
+
packages = ["src/repokit"]
|
|
39
|
+
|
|
40
|
+
[tool.pytest.ini_options]
|
|
41
|
+
testpaths = ["tests"]
|
|
42
|
+
asyncio_mode = "auto"
|
|
43
|
+
|
|
44
|
+
[tool.uv.sources]
|
|
45
|
+
asyncgh = { workspace = true }
|
|
46
|
+
reconcilekit = { workspace = true }
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""repokit -- repo listing/filtering and CLI-entrypoint plumbing shared by
|
|
2
|
+
repo-admin's scripts.
|
|
3
|
+
|
|
4
|
+
Fetches repos via `asyncgh` and runs bounded-parallel work over them via
|
|
5
|
+
`reconcilekit`; adds a `Repo`/`RepoResult` shape and a couple of CLI
|
|
6
|
+
conveniences (`run_cli`, `as_set`) on top. No config-file loading, no sops,
|
|
7
|
+
no fork/exclude policy -- those stay with the caller, which is why
|
|
8
|
+
`list_repos`'s `include_forks` defaults to none included rather than to any
|
|
9
|
+
file-backed default.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from .core import (
|
|
15
|
+
DEFAULT_JOBS,
|
|
16
|
+
DEFAULT_OWNER,
|
|
17
|
+
Repo,
|
|
18
|
+
RepoResult,
|
|
19
|
+
as_set,
|
|
20
|
+
filter_repos,
|
|
21
|
+
list_repos,
|
|
22
|
+
run_cli,
|
|
23
|
+
run_parallel,
|
|
24
|
+
)
|
|
25
|
+
from .email import send_email, send_email_from_env
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"DEFAULT_JOBS",
|
|
29
|
+
"DEFAULT_OWNER",
|
|
30
|
+
"Repo",
|
|
31
|
+
"RepoResult",
|
|
32
|
+
"as_set",
|
|
33
|
+
"filter_repos",
|
|
34
|
+
"list_repos",
|
|
35
|
+
"run_cli",
|
|
36
|
+
"run_parallel",
|
|
37
|
+
"send_email",
|
|
38
|
+
"send_email_from_env",
|
|
39
|
+
]
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Repo listing/filtering and the CLI-entrypoint plumbing shared by
|
|
2
|
+
repo-admin's scripts.
|
|
3
|
+
|
|
4
|
+
Fetches repos via `asyncgh.fetch_repos` and runs bounded-parallel work over
|
|
5
|
+
them via `reconcilekit.run_parallel` -- this module adds nothing beyond
|
|
6
|
+
that except the `Repo`/`RepoResult` shapes and a couple of CLI conveniences
|
|
7
|
+
(`run_cli`, `as_set`). Config-file-backed policy (which forks to include,
|
|
8
|
+
which repos to exclude from a specific check, secrets) is a caller concern,
|
|
9
|
+
not this package's -- `include_forks` here is just a plain set, with no
|
|
10
|
+
default derived from any file.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import asyncio
|
|
16
|
+
import os
|
|
17
|
+
import sys
|
|
18
|
+
from collections.abc import Awaitable, Callable
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from typing import TypeVar, cast
|
|
21
|
+
|
|
22
|
+
from asyncgh import GhError, aclose_client, fetch_repos
|
|
23
|
+
from reconcilekit import Status
|
|
24
|
+
from reconcilekit import run_parallel as _run_parallel
|
|
25
|
+
|
|
26
|
+
DEFAULT_OWNER = os.environ.get("GH_OWNER", "hugoh")
|
|
27
|
+
DEFAULT_JOBS = int(os.environ.get("GH_JOBS", "6"))
|
|
28
|
+
|
|
29
|
+
_Args = TypeVar("_Args")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class Repo:
|
|
34
|
+
name: str
|
|
35
|
+
default_branch: str
|
|
36
|
+
is_private: bool
|
|
37
|
+
is_fork: bool
|
|
38
|
+
homepage: str = ""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class RepoResult:
|
|
43
|
+
repo: Repo
|
|
44
|
+
line: str
|
|
45
|
+
status: Status = Status.OK
|
|
46
|
+
tag: str | None = None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def as_set(value: str | None) -> set[str] | None:
|
|
50
|
+
"""Splits a comma-separated string into a set, or `None` if `value` is
|
|
51
|
+
falsy -- the CLI-argument convention every repo-admin script's
|
|
52
|
+
`--skip`/repo-list arguments share.
|
|
53
|
+
"""
|
|
54
|
+
if not value:
|
|
55
|
+
return None
|
|
56
|
+
return {v.strip() for v in value.split(",") if v.strip()}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def filter_repos(
|
|
60
|
+
repos_json: list[dict],
|
|
61
|
+
*,
|
|
62
|
+
only: set[str] | None = None,
|
|
63
|
+
skip: set[str] | None = None,
|
|
64
|
+
include_forks: set[str] | None = None,
|
|
65
|
+
) -> list[Repo]:
|
|
66
|
+
"""Keeps every non-archived repo that's either not a fork or is listed in
|
|
67
|
+
include_forks, then applies only/skip.
|
|
68
|
+
"""
|
|
69
|
+
include_forks = include_forks or set()
|
|
70
|
+
repos = []
|
|
71
|
+
for entry in repos_json:
|
|
72
|
+
if entry["archived"]:
|
|
73
|
+
continue
|
|
74
|
+
name = entry["name"]
|
|
75
|
+
if entry["fork"] and name not in include_forks:
|
|
76
|
+
continue
|
|
77
|
+
if only and name not in only:
|
|
78
|
+
continue
|
|
79
|
+
if skip and name in skip:
|
|
80
|
+
continue
|
|
81
|
+
repos.append(
|
|
82
|
+
Repo(
|
|
83
|
+
name=name,
|
|
84
|
+
default_branch=entry.get("default_branch") or "",
|
|
85
|
+
is_private=entry["private"],
|
|
86
|
+
is_fork=entry["fork"],
|
|
87
|
+
homepage=entry.get("homepage") or "",
|
|
88
|
+
)
|
|
89
|
+
)
|
|
90
|
+
return repos
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
async def list_repos(
|
|
94
|
+
owner: str = DEFAULT_OWNER,
|
|
95
|
+
*,
|
|
96
|
+
only: set[str] | None = None,
|
|
97
|
+
skip: set[str] | None = None,
|
|
98
|
+
include_forks: set[str] | None = None,
|
|
99
|
+
) -> list[Repo]:
|
|
100
|
+
"""Fetches every repo for `owner` and applies `filter_repos`.
|
|
101
|
+
`include_forks` defaults to none included -- callers with a
|
|
102
|
+
file/env-backed fork policy (like repo-admin's) resolve it themselves
|
|
103
|
+
and pass it in.
|
|
104
|
+
"""
|
|
105
|
+
# RepoJSON (a TypedDict) isn't assignable to plain dict per ty -- these
|
|
106
|
+
# helpers work on repo JSON generically, not asyncgh's specific shape.
|
|
107
|
+
repos_json = cast("list[dict]", await fetch_repos(owner))
|
|
108
|
+
return filter_repos(repos_json, only=only, skip=skip, include_forks=include_forks)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def run_cli(
|
|
112
|
+
entrypoint: Callable[[_Args], Awaitable[int]],
|
|
113
|
+
args: _Args,
|
|
114
|
+
) -> int:
|
|
115
|
+
"""Runs an async CLI entrypoint under asyncio, always closing the shared
|
|
116
|
+
HTTP client afterwards and turning a GhError into a stderr message plus
|
|
117
|
+
exit code 1 -- the wrapper every repo-admin-style script's main() shares.
|
|
118
|
+
"""
|
|
119
|
+
|
|
120
|
+
async def _run() -> int:
|
|
121
|
+
try:
|
|
122
|
+
return await entrypoint(args)
|
|
123
|
+
finally:
|
|
124
|
+
await aclose_client()
|
|
125
|
+
|
|
126
|
+
try:
|
|
127
|
+
return asyncio.run(_run())
|
|
128
|
+
except GhError as exc:
|
|
129
|
+
print(exc, file=sys.stderr)
|
|
130
|
+
return 1
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
async def run_parallel(
|
|
134
|
+
repos: list[Repo], worker, *, jobs: int = DEFAULT_JOBS, verbose: bool = False
|
|
135
|
+
) -> list[RepoResult]:
|
|
136
|
+
"""reconcilekit.run_parallel with GhError bound as the failure exception
|
|
137
|
+
type. See reconcilekit.kernel for the concurrency, failure-isolation,
|
|
138
|
+
and quiet-suppression behaviour.
|
|
139
|
+
"""
|
|
140
|
+
return await _run_parallel(
|
|
141
|
+
repos, worker, jobs=jobs, verbose=verbose, error_cls=GhError
|
|
142
|
+
)
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Sends a rendered HTML report via an SMTP relay.
|
|
2
|
+
|
|
3
|
+
Kept separate from repo-fetching concerns so a consumer can swap this for a
|
|
4
|
+
Slack post, a workflow artifact, or nothing at all, without touching how it
|
|
5
|
+
fetches or renders a report.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import smtplib
|
|
12
|
+
from email.mime.multipart import MIMEMultipart
|
|
13
|
+
from email.mime.text import MIMEText
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def send_email(
|
|
17
|
+
html_body: str,
|
|
18
|
+
*,
|
|
19
|
+
smtp_host: str,
|
|
20
|
+
smtp_port: int,
|
|
21
|
+
smtp_user: str,
|
|
22
|
+
smtp_password: str,
|
|
23
|
+
from_addr: str,
|
|
24
|
+
to_addr: str,
|
|
25
|
+
subject: str,
|
|
26
|
+
) -> None:
|
|
27
|
+
msg = MIMEMultipart("alternative")
|
|
28
|
+
msg["Subject"] = subject
|
|
29
|
+
msg["From"] = from_addr
|
|
30
|
+
msg["To"] = to_addr
|
|
31
|
+
msg.attach(MIMEText("This email requires an HTML-capable mail client.", "plain"))
|
|
32
|
+
msg.attach(MIMEText(html_body, "html"))
|
|
33
|
+
|
|
34
|
+
with smtplib.SMTP(smtp_host, smtp_port) as server:
|
|
35
|
+
server.starttls()
|
|
36
|
+
server.login(smtp_user, smtp_password)
|
|
37
|
+
server.send_message(msg)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def send_email_from_env(html_body: str, *, subject: str) -> None:
|
|
41
|
+
"""send_email(), reading the relay and recipient from the environment:
|
|
42
|
+
SMTP_HOST, SMTP_PORT, SMTP_USERNAME, SMTP_PASSWORD, DIGEST_FROM_EMAIL,
|
|
43
|
+
DIGEST_TO_EMAIL.
|
|
44
|
+
"""
|
|
45
|
+
send_email(
|
|
46
|
+
html_body,
|
|
47
|
+
smtp_host=os.environ["SMTP_HOST"],
|
|
48
|
+
smtp_port=int(os.environ["SMTP_PORT"]),
|
|
49
|
+
smtp_user=os.environ["SMTP_USERNAME"],
|
|
50
|
+
smtp_password=os.environ["SMTP_PASSWORD"],
|
|
51
|
+
from_addr=os.environ["DIGEST_FROM_EMAIL"],
|
|
52
|
+
to_addr=os.environ["DIGEST_TO_EMAIL"],
|
|
53
|
+
subject=subject,
|
|
54
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
|
|
3
|
+
from asyncgh import GhError
|
|
4
|
+
from reconcilekit import Status
|
|
5
|
+
from repokit import Repo, RepoResult, filter_repos, run_cli
|
|
6
|
+
|
|
7
|
+
REPOS_JSON = [
|
|
8
|
+
{
|
|
9
|
+
"name": "public-repo",
|
|
10
|
+
"fork": False,
|
|
11
|
+
"archived": False,
|
|
12
|
+
"private": False,
|
|
13
|
+
"default_branch": "main",
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"name": "archived-repo",
|
|
17
|
+
"fork": False,
|
|
18
|
+
"archived": True,
|
|
19
|
+
"private": False,
|
|
20
|
+
"default_branch": "main",
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"name": "fork-repo",
|
|
24
|
+
"fork": True,
|
|
25
|
+
"archived": False,
|
|
26
|
+
"private": False,
|
|
27
|
+
"default_branch": "main",
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
"name": "maintained-fork",
|
|
31
|
+
"fork": True,
|
|
32
|
+
"archived": False,
|
|
33
|
+
"private": True,
|
|
34
|
+
"default_branch": "master",
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
"name": "empty-repo",
|
|
38
|
+
"fork": False,
|
|
39
|
+
"archived": False,
|
|
40
|
+
"private": False,
|
|
41
|
+
"default_branch": None,
|
|
42
|
+
},
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_filter_repos_excludes_archived():
|
|
47
|
+
repos = filter_repos(REPOS_JSON)
|
|
48
|
+
assert "archived-repo" not in [r.name for r in repos]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_filter_repos_excludes_forks_by_default():
|
|
52
|
+
repos = filter_repos(REPOS_JSON)
|
|
53
|
+
assert "fork-repo" not in [r.name for r in repos]
|
|
54
|
+
assert "maintained-fork" not in [r.name for r in repos]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_filter_repos_includes_listed_forks():
|
|
58
|
+
repos = filter_repos(REPOS_JSON, include_forks={"maintained-fork"})
|
|
59
|
+
names = [r.name for r in repos]
|
|
60
|
+
assert "maintained-fork" in names
|
|
61
|
+
assert "fork-repo" not in names
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_filter_repos_only_filter():
|
|
65
|
+
repos = filter_repos(REPOS_JSON, only={"public-repo"})
|
|
66
|
+
assert [r.name for r in repos] == ["public-repo"]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_filter_repos_skip_filter():
|
|
70
|
+
repos = filter_repos(REPOS_JSON, skip={"public-repo"})
|
|
71
|
+
assert "public-repo" not in [r.name for r in repos]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_filter_repos_fields():
|
|
75
|
+
repos = filter_repos(REPOS_JSON, only={"public-repo"})
|
|
76
|
+
assert repos == [
|
|
77
|
+
Repo(name="public-repo", default_branch="main", is_private=False, is_fork=False)
|
|
78
|
+
]
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def test_filter_repos_reads_homepage():
|
|
82
|
+
repos_json = [
|
|
83
|
+
{
|
|
84
|
+
"name": "site-repo",
|
|
85
|
+
"fork": False,
|
|
86
|
+
"archived": False,
|
|
87
|
+
"private": False,
|
|
88
|
+
"default_branch": "main",
|
|
89
|
+
"homepage": "https://example.com",
|
|
90
|
+
}
|
|
91
|
+
]
|
|
92
|
+
assert filter_repos(repos_json)[0].homepage == "https://example.com"
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def test_filter_repos_homepage_defaults_to_empty_when_null():
|
|
96
|
+
repos = filter_repos(REPOS_JSON, only={"public-repo"})
|
|
97
|
+
assert repos[0].homepage == ""
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def test_filter_repos_handles_null_default_branch():
|
|
101
|
+
repos = filter_repos(REPOS_JSON, only={"empty-repo"})
|
|
102
|
+
assert repos[0].default_branch == ""
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def test_repo_result_defaults_to_ok_status():
|
|
106
|
+
repo = Repo(name="repo", default_branch="main", is_private=False, is_fork=False)
|
|
107
|
+
assert RepoResult(repo, "line").status == Status.OK
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def test_run_cli_runs_entrypoint_and_closes_client(monkeypatch):
|
|
111
|
+
closed = []
|
|
112
|
+
monkeypatch.setattr("repokit.core.aclose_client", _record_close(closed))
|
|
113
|
+
|
|
114
|
+
async def entrypoint(args):
|
|
115
|
+
assert args == "ARGS"
|
|
116
|
+
return 0
|
|
117
|
+
|
|
118
|
+
assert run_cli(entrypoint, "ARGS") == 0
|
|
119
|
+
assert closed == [True]
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def test_run_cli_closes_client_even_when_entrypoint_raises(monkeypatch):
|
|
123
|
+
closed = []
|
|
124
|
+
monkeypatch.setattr("repokit.core.aclose_client", _record_close(closed))
|
|
125
|
+
|
|
126
|
+
async def entrypoint(args):
|
|
127
|
+
raise RuntimeError("boom")
|
|
128
|
+
|
|
129
|
+
with pytest.raises(RuntimeError, match="boom"):
|
|
130
|
+
run_cli(entrypoint, None)
|
|
131
|
+
assert closed == [True]
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def test_run_cli_converts_gh_error_to_exit_code_1(monkeypatch, capsys):
|
|
135
|
+
monkeypatch.setattr("repokit.core.aclose_client", _record_close([]))
|
|
136
|
+
|
|
137
|
+
async def entrypoint(args):
|
|
138
|
+
raise GhError("nope")
|
|
139
|
+
|
|
140
|
+
assert run_cli(entrypoint, None) == 1
|
|
141
|
+
assert "nope" in capsys.readouterr().err
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _record_close(sink):
|
|
145
|
+
async def _aclose():
|
|
146
|
+
sink.append(True)
|
|
147
|
+
|
|
148
|
+
return _aclose
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
from email import message_from_bytes
|
|
2
|
+
from typing import ClassVar
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
from repokit.email import send_email, send_email_from_env
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class FakeSMTP:
|
|
9
|
+
instances: ClassVar[list] = []
|
|
10
|
+
|
|
11
|
+
def __init__(self, host, port):
|
|
12
|
+
self.host = host
|
|
13
|
+
self.port = port
|
|
14
|
+
self.calls = []
|
|
15
|
+
FakeSMTP.instances.append(self)
|
|
16
|
+
|
|
17
|
+
def __enter__(self):
|
|
18
|
+
return self
|
|
19
|
+
|
|
20
|
+
def __exit__(self, *exc_info):
|
|
21
|
+
return False
|
|
22
|
+
|
|
23
|
+
def starttls(self):
|
|
24
|
+
self.calls.append("starttls")
|
|
25
|
+
|
|
26
|
+
def login(self, user, password):
|
|
27
|
+
self.calls.append(("login", user, password))
|
|
28
|
+
|
|
29
|
+
def send_message(self, msg):
|
|
30
|
+
self.calls.append(("send_message", msg))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@pytest.fixture(autouse=True)
|
|
34
|
+
def fake_smtp(monkeypatch):
|
|
35
|
+
FakeSMTP.instances = []
|
|
36
|
+
monkeypatch.setattr("smtplib.SMTP", FakeSMTP)
|
|
37
|
+
return FakeSMTP
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_send_email_starttls_login_and_sends(fake_smtp):
|
|
41
|
+
send_email(
|
|
42
|
+
"<html>hi</html>",
|
|
43
|
+
smtp_host="smtp.example.com",
|
|
44
|
+
smtp_port=587,
|
|
45
|
+
smtp_user="user",
|
|
46
|
+
smtp_password="pass",
|
|
47
|
+
from_addr="from@example.com",
|
|
48
|
+
to_addr="to@example.com",
|
|
49
|
+
subject="PR digest",
|
|
50
|
+
)
|
|
51
|
+
(instance,) = fake_smtp.instances
|
|
52
|
+
assert instance.host == "smtp.example.com"
|
|
53
|
+
assert instance.port == 587
|
|
54
|
+
assert instance.calls[0] == "starttls"
|
|
55
|
+
assert instance.calls[1] == ("login", "user", "pass")
|
|
56
|
+
assert instance.calls[2][0] == "send_message"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_send_email_message_has_plain_and_html_parts(fake_smtp):
|
|
60
|
+
send_email(
|
|
61
|
+
"<html><body>hi</body></html>",
|
|
62
|
+
smtp_host="smtp.example.com",
|
|
63
|
+
smtp_port=587,
|
|
64
|
+
smtp_user="user",
|
|
65
|
+
smtp_password="pass",
|
|
66
|
+
from_addr="from@example.com",
|
|
67
|
+
to_addr="to@example.com",
|
|
68
|
+
subject="PR digest",
|
|
69
|
+
)
|
|
70
|
+
(instance,) = fake_smtp.instances
|
|
71
|
+
msg = instance.calls[2][1]
|
|
72
|
+
parsed = message_from_bytes(msg.as_bytes())
|
|
73
|
+
content_types = {part.get_content_type() for part in parsed.walk()}
|
|
74
|
+
assert "text/plain" in content_types
|
|
75
|
+
assert "text/html" in content_types
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_send_email_sets_subject_and_addresses(fake_smtp):
|
|
79
|
+
send_email(
|
|
80
|
+
"<html>hi</html>",
|
|
81
|
+
smtp_host="smtp.example.com",
|
|
82
|
+
smtp_port=587,
|
|
83
|
+
smtp_user="user",
|
|
84
|
+
smtp_password="pass",
|
|
85
|
+
from_addr="from@example.com",
|
|
86
|
+
to_addr="to@example.com",
|
|
87
|
+
subject="PR digest",
|
|
88
|
+
)
|
|
89
|
+
(instance,) = fake_smtp.instances
|
|
90
|
+
msg = instance.calls[2][1]
|
|
91
|
+
assert msg["Subject"] == "PR digest"
|
|
92
|
+
assert msg["From"] == "from@example.com"
|
|
93
|
+
assert msg["To"] == "to@example.com"
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def test_send_email_from_env_reads_smtp_settings_from_environment(
|
|
97
|
+
fake_smtp, monkeypatch
|
|
98
|
+
):
|
|
99
|
+
monkeypatch.setenv("SMTP_HOST", "smtp.example.com")
|
|
100
|
+
monkeypatch.setenv("SMTP_PORT", "587")
|
|
101
|
+
monkeypatch.setenv("SMTP_USERNAME", "user")
|
|
102
|
+
monkeypatch.setenv("SMTP_PASSWORD", "pass")
|
|
103
|
+
monkeypatch.setenv("DIGEST_FROM_EMAIL", "from@example.com")
|
|
104
|
+
monkeypatch.setenv("DIGEST_TO_EMAIL", "to@example.com")
|
|
105
|
+
|
|
106
|
+
send_email_from_env("<html>hi</html>", subject="PR digest")
|
|
107
|
+
|
|
108
|
+
(instance,) = fake_smtp.instances
|
|
109
|
+
assert instance.host == "smtp.example.com"
|
|
110
|
+
assert instance.port == 587
|
|
111
|
+
assert instance.calls[1] == ("login", "user", "pass")
|
|
112
|
+
msg = instance.calls[2][1]
|
|
113
|
+
assert msg["Subject"] == "PR digest"
|
|
114
|
+
assert msg["From"] == "from@example.com"
|
|
115
|
+
assert msg["To"] == "to@example.com"
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def test_send_email_from_env_raises_on_missing_env_var(fake_smtp, monkeypatch):
|
|
119
|
+
monkeypatch.delenv("SMTP_HOST", raising=False)
|
|
120
|
+
with pytest.raises(KeyError):
|
|
121
|
+
send_email_from_env("<html>hi</html>", subject="PR digest")
|