ferrygl 0.1.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.
ferrygl/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """ferryGL: copy your GitHub work into your own Linear workspace."""
2
+
3
+ __version__ = "0.1.0"
ferrygl/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from ferrygl.cli import main
2
+
3
+ raise SystemExit(main())
ferrygl/_http.py ADDED
@@ -0,0 +1,81 @@
1
+ """JSON over HTTP with retries, shared by the GitHub and Linear clients."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from collections.abc import Callable
7
+ from typing import Any
8
+
9
+ import httpx
10
+
11
+ RETRY_STATUSES = frozenset({429, 502, 503, 504})
12
+
13
+
14
+ class HttpError(Exception):
15
+ """A request failed, or returned an error status without a GraphQL ``errors`` body."""
16
+
17
+ def __init__(self, message: str, status: int | None = None) -> None:
18
+ super().__init__(message)
19
+ self.status = status
20
+
21
+
22
+ def post_json(
23
+ client: httpx.Client,
24
+ url: str,
25
+ payload: dict[str, Any],
26
+ headers: dict[str, str],
27
+ *,
28
+ attempts: int = 3,
29
+ sleep: Callable[[float], None] = time.sleep,
30
+ idempotent: bool = True,
31
+ ) -> dict[str, Any]:
32
+ """POST ``payload`` and return the decoded JSON object.
33
+
34
+ Rate-limited responses are retried, honoring ``Retry-After``. When ``idempotent``,
35
+ network errors and temporarily unavailable responses are retried too; otherwise they
36
+ are raised, because the server may have acted on the request already. An error status
37
+ whose body carries GraphQL ``errors`` is returned rather than raised, so the caller can
38
+ report the server's own message.
39
+ """
40
+ for attempt in range(1, attempts + 1):
41
+ try:
42
+ response = client.post(url, json=payload, headers=headers)
43
+ except httpx.TransportError as exc:
44
+ if attempt == attempts or not idempotent:
45
+ raise HttpError(f"could not reach {url}: {exc}") from exc
46
+ sleep(float(2**attempt))
47
+ continue
48
+ if attempt < attempts and _should_retry(response, idempotent):
49
+ sleep(_retry_after(response, default=float(2**attempt)))
50
+ continue
51
+ return _decode(url, response)
52
+ raise ValueError("attempts must be at least 1")
53
+
54
+
55
+ def _should_retry(response: httpx.Response, idempotent: bool) -> bool:
56
+ if response.status_code == 429:
57
+ return True
58
+ # GitHub reports secondary rate limits as 403 with Retry-After.
59
+ if response.status_code == 403 and "retry-after" in response.headers:
60
+ return True
61
+ return idempotent and response.status_code in RETRY_STATUSES
62
+
63
+
64
+ def _retry_after(response: httpx.Response, default: float) -> float:
65
+ try:
66
+ return min(float(response.headers["retry-after"]), 60.0)
67
+ except (KeyError, ValueError):
68
+ return default
69
+
70
+
71
+ def _decode(url: str, response: httpx.Response) -> dict[str, Any]:
72
+ try:
73
+ body = response.json()
74
+ except ValueError:
75
+ body = None
76
+ if isinstance(body, dict) and (response.status_code < 400 or "errors" in body):
77
+ return body
78
+ snippet = response.text[:200].strip()
79
+ raise HttpError(
80
+ f"{url} returned HTTP {response.status_code}: {snippet}", status=response.status_code
81
+ )
ferrygl/cli.py ADDED
@@ -0,0 +1,92 @@
1
+ """The ``ferrygl`` command."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import os
7
+ import sys
8
+ from collections.abc import Mapping, Sequence
9
+ from datetime import UTC, datetime
10
+ from pathlib import Path
11
+
12
+ from ferrygl import __version__
13
+ from ferrygl.config import Config, ConfigError, default_path, load
14
+ from ferrygl.github import GitHubClient, resolve_token
15
+ from ferrygl.linear import LinearClient
16
+ from ferrygl.sync import EXIT_SETUP, GitHubFactory, run_check, run_sync
17
+
18
+
19
+ def main(argv: Sequence[str] | None = None) -> int:
20
+ args = _parser().parse_args(argv)
21
+ try:
22
+ config = load(args.config or default_path())
23
+ api_key = linear_api_key(config)
24
+ except ConfigError as exc:
25
+ print(f"error: {exc}", file=sys.stderr)
26
+ return EXIT_SETUP
27
+
28
+ linear = LinearClient(api_key)
29
+ github_for = _github_factory()
30
+ try:
31
+ if args.command == "check":
32
+ return run_check(config, linear=linear, github_for=github_for, out=sys.stdout)
33
+ return run_sync(
34
+ config,
35
+ apply=args.apply,
36
+ linear=linear,
37
+ github_for=github_for,
38
+ out=sys.stdout,
39
+ err=sys.stderr,
40
+ now=datetime.now(UTC).isoformat(timespec="seconds"),
41
+ )
42
+ except KeyboardInterrupt:
43
+ return 130
44
+
45
+
46
+ def linear_api_key(config: Config, env: Mapping[str, str] = os.environ) -> str:
47
+ """``LINEAR_API_KEY``, else the contents of ``[linear] api_key_file``."""
48
+ if key := env.get("LINEAR_API_KEY", "").strip():
49
+ return key
50
+ path = config.linear_api_key_file
51
+ if path is None:
52
+ raise ConfigError("no Linear API key: set LINEAR_API_KEY or [linear] api_key_file")
53
+ try:
54
+ key = path.read_text(encoding="utf-8").strip()
55
+ except OSError as exc:
56
+ raise ConfigError(f"{path}: {exc.strerror}") from None
57
+ if not key:
58
+ raise ConfigError(f"{path}: the Linear API key file is empty")
59
+ return key
60
+
61
+
62
+ def _github_factory() -> GitHubFactory:
63
+ clients: dict[str | None, GitHubClient] = {}
64
+
65
+ def github_for(user: str | None) -> GitHubClient:
66
+ if user not in clients:
67
+ clients[user] = GitHubClient(resolve_token(user))
68
+ return clients[user]
69
+
70
+ return github_for
71
+
72
+
73
+ def _parser() -> argparse.ArgumentParser:
74
+ parser = argparse.ArgumentParser(
75
+ prog="ferrygl",
76
+ description="Copy your GitHub issues and pull requests into your own Linear workspace.",
77
+ )
78
+ parser.add_argument("--version", action="version", version=f"ferrygl {__version__}")
79
+ commands = parser.add_subparsers(dest="command", required=True, metavar="command")
80
+ config_help = f"configuration file (default: {default_path()})"
81
+
82
+ check = commands.add_parser(
83
+ "check", help="check credentials, repositories and Linear settings; writes nothing"
84
+ )
85
+ check.add_argument("--config", type=Path, help=config_help)
86
+
87
+ sync = commands.add_parser(
88
+ "sync", help="show the changes a sync would make; --apply makes them in Linear"
89
+ )
90
+ sync.add_argument("--config", type=Path, help=config_help)
91
+ sync.add_argument("--apply", action="store_true", help="make the changes in Linear")
92
+ return parser
ferrygl/config.py ADDED
@@ -0,0 +1,271 @@
1
+ """Read and validate ``ferrygl.toml``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import re
7
+ import string
8
+ import tomllib
9
+ from dataclasses import dataclass, field
10
+ from datetime import date, datetime
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ CATEGORIES = ("todo", "in_progress", "in_review", "done", "canceled")
15
+ TITLE_FIELDS = frozenset({"title", "number", "repo"})
16
+ _REPO = re.compile(r"^[A-Za-z0-9_.-]+/([A-Za-z0-9_.-]+|\*)$")
17
+
18
+
19
+ class ConfigError(Exception):
20
+ """The configuration file is missing or invalid."""
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class Route:
25
+ """Repositories whose items go to one Linear team and, optionally, one project."""
26
+
27
+ repos: tuple[str, ...] # "owner/name", or "owner/*" for every repository of an owner
28
+ team: str # Linear team key or name
29
+ project: str | None = None
30
+ github_user: str | None = None # read GitHub as this GitHub CLI account
31
+
32
+ @property
33
+ def name(self) -> str:
34
+ target = self.team if self.project is None else f"{self.team} / {self.project}"
35
+ return f"{', '.join(self.repos)} -> {target}"
36
+
37
+ @property
38
+ def single_repo(self) -> str | None:
39
+ """The repository, when the route names exactly one and no wildcard."""
40
+ if len(self.repos) == 1 and not self.repos[0].endswith("/*"):
41
+ return self.repos[0]
42
+ return None
43
+
44
+
45
+ SYNC_FLAGS = (
46
+ "issues_assigned",
47
+ "issues_created",
48
+ "prs_created",
49
+ "prs_assigned",
50
+ "prs_reviewed",
51
+ "review_requests",
52
+ "prs_with_commits",
53
+ )
54
+
55
+
56
+ @dataclass(frozen=True)
57
+ class SyncOptions:
58
+ """Which of your roles on GitHub bring an item into Linear. All are on by default."""
59
+
60
+ issues_assigned: bool = True
61
+ issues_created: bool = True
62
+ prs_created: bool = True
63
+ prs_assigned: bool = True # someone else's pull request assigned to you
64
+ prs_reviewed: bool = True
65
+ review_requests: bool = True
66
+ prs_with_commits: bool = True # someone else's pull request with a commit you wrote
67
+ since: date | None = None
68
+ label: str | None = None
69
+
70
+
71
+ @dataclass(frozen=True)
72
+ class Titles:
73
+ issue: str = "{title} #{number}"
74
+ pr: str = "{title} - #{number}"
75
+ involved: str = "[PR] {title} - #{number}"
76
+
77
+
78
+ @dataclass(frozen=True)
79
+ class StatusNames:
80
+ """Your Linear team's workflow state names for each status ferryGL sets."""
81
+
82
+ todo: str = "Todo"
83
+ in_progress: str = "In Progress"
84
+ in_review: str = "In Review"
85
+ done: str = "Done"
86
+ canceled: str = "Canceled"
87
+
88
+ def name(self, category: str) -> str:
89
+ return getattr(self, category)
90
+
91
+ def all(self) -> tuple[str, ...]:
92
+ return tuple(self.name(category) for category in CATEGORIES)
93
+
94
+
95
+ @dataclass(frozen=True)
96
+ class Config:
97
+ routes: tuple[Route, ...]
98
+ sync: SyncOptions = field(default_factory=SyncOptions)
99
+ titles: Titles = field(default_factory=Titles)
100
+ status: StatusNames = field(default_factory=StatusNames)
101
+ copy_description: bool = False
102
+ linear_api_key_file: Path | None = None
103
+
104
+
105
+ def default_path() -> Path:
106
+ """``$FERRYGL_CONFIG``, else ``ferrygl/ferrygl.toml`` in the user's config directory."""
107
+ if explicit := os.environ.get("FERRYGL_CONFIG"):
108
+ return Path(explicit).expanduser()
109
+ base = os.environ.get("XDG_CONFIG_HOME") or "~/.config"
110
+ return Path(base).expanduser() / "ferrygl" / "ferrygl.toml"
111
+
112
+
113
+ def load(path: Path) -> Config:
114
+ try:
115
+ text = path.read_text(encoding="utf-8")
116
+ except FileNotFoundError:
117
+ raise ConfigError(f"{path}: file not found") from None
118
+ except OSError as exc:
119
+ raise ConfigError(f"{path}: {exc.strerror}") from None
120
+ try:
121
+ data = tomllib.loads(text)
122
+ except tomllib.TOMLDecodeError as exc:
123
+ raise ConfigError(f"{path}: {exc}") from None
124
+ return parse(data, source=str(path))
125
+
126
+
127
+ def parse(data: dict[str, Any], source: str = "config") -> Config:
128
+ _check_keys(data, {"route", "sync", "titles", "copy", "status", "linear"}, source)
129
+ raw_routes = data.get("route")
130
+ if not raw_routes:
131
+ raise ConfigError(f"{source}: add at least one [[route]] table")
132
+ if not isinstance(raw_routes, list):
133
+ raise ConfigError(f"{source}: write routes as [[route]] tables")
134
+ routes = tuple(_route(raw, f"{source}: route {n}") for n, raw in enumerate(raw_routes, 1))
135
+ _check_overlap(routes, source)
136
+
137
+ copy = _table(data, "copy", source)
138
+ _check_keys(copy, {"description"}, f"{source}: [copy]")
139
+ linear = _table(data, "linear", source)
140
+ _check_keys(linear, {"api_key_file"}, f"{source}: [linear]")
141
+ key_file = _optional_str(linear, "api_key_file", f"{source}: [linear]")
142
+ return Config(
143
+ routes=routes,
144
+ sync=_sync(_table(data, "sync", source), f"{source}: [sync]"),
145
+ titles=_titles(_table(data, "titles", source), f"{source}: [titles]"),
146
+ status=_status(_table(data, "status", source), f"{source}: [status]"),
147
+ copy_description=_bool(copy, "description", False, f"{source}: [copy]"),
148
+ linear_api_key_file=Path(key_file).expanduser() if key_file else None,
149
+ )
150
+
151
+
152
+ def _route(raw: Any, where: str) -> Route:
153
+ if not isinstance(raw, dict):
154
+ raise ConfigError(f"{where}: must be a table")
155
+ _check_keys(raw, {"repos", "team", "project", "github_user"}, where)
156
+ repos = raw.get("repos")
157
+ if not isinstance(repos, list) or not repos or not all(isinstance(r, str) for r in repos):
158
+ raise ConfigError(f'{where}: `repos` must be a non-empty list such as ["owner/name"]')
159
+ for repo in repos:
160
+ if not _REPO.match(repo):
161
+ raise ConfigError(f'{where}: "{repo}" is not "owner/name" or "owner/*"')
162
+ team = raw.get("team")
163
+ if not isinstance(team, str) or not team.strip():
164
+ raise ConfigError(f"{where}: `team` must name a Linear team, by key or name")
165
+ return Route(
166
+ repos=tuple(repos),
167
+ team=team.strip(),
168
+ project=_optional_str(raw, "project", where),
169
+ github_user=_optional_str(raw, "github_user", where),
170
+ )
171
+
172
+
173
+ def _check_overlap(routes: tuple[Route, ...], source: str) -> None:
174
+ """Each repository must belong to exactly one route, or its items would be planned twice."""
175
+ entries = [(n, repo) for n, route in enumerate(routes, 1) for repo in route.repos]
176
+ for i, (n, a) in enumerate(entries):
177
+ for m, b in entries[i + 1 :]:
178
+ if _covers(a, b) or _covers(b, a):
179
+ raise ConfigError(
180
+ f'{source}: "{a}" (route {n}) and "{b}" (route {m}) overlap; '
181
+ "send each repository to exactly one route"
182
+ )
183
+
184
+
185
+ def _covers(pattern: str, repo: str) -> bool:
186
+ owner, name = pattern.lower().split("/", 1)
187
+ return pattern.lower() == repo.lower() or (name == "*" and repo.lower().startswith(f"{owner}/"))
188
+
189
+
190
+ def _sync(raw: dict[str, Any], where: str) -> SyncOptions:
191
+ flags = SYNC_FLAGS
192
+ _check_keys(raw, {*flags, "since", "label"}, where)
193
+ since = raw.get("since")
194
+ if isinstance(since, datetime):
195
+ since = since.date()
196
+ elif isinstance(since, str):
197
+ try:
198
+ since = date.fromisoformat(since)
199
+ except ValueError:
200
+ raise ConfigError(f"{where}: `since` must be a date such as 2026-09-01") from None
201
+ elif since is not None and not isinstance(since, date):
202
+ raise ConfigError(f"{where}: `since` must be a date such as 2026-09-01")
203
+ return SyncOptions(
204
+ **{flag: _bool(raw, flag, True, where) for flag in flags},
205
+ since=since,
206
+ label=_optional_str(raw, "label", where),
207
+ )
208
+
209
+
210
+ def _titles(raw: dict[str, Any], where: str) -> Titles:
211
+ _check_keys(raw, {"issue", "pr", "involved"}, where)
212
+ defaults = Titles()
213
+ values = {}
214
+ for key in ("issue", "pr", "involved"):
215
+ value = raw.get(key, getattr(defaults, key))
216
+ if not isinstance(value, str) or not value.strip():
217
+ raise ConfigError(f"{where}: `{key}` must be a non-empty string")
218
+ try:
219
+ fields = {f for _, f, _, _ in string.Formatter().parse(value) if f is not None}
220
+ except ValueError as exc:
221
+ raise ConfigError(f"{where}: `{key}`: {exc}") from None
222
+ unknown = sorted(fields - TITLE_FIELDS)
223
+ if unknown:
224
+ shown = ", ".join("{" + f + "}" for f in unknown)
225
+ raise ConfigError(
226
+ f"{where}: `{key}` uses {shown}; use {{title}}, {{number}} or {{repo}}"
227
+ )
228
+ values[key] = value
229
+ return Titles(**values)
230
+
231
+
232
+ def _status(raw: dict[str, Any], where: str) -> StatusNames:
233
+ _check_keys(raw, set(CATEGORIES), where)
234
+ defaults = StatusNames()
235
+ values = {}
236
+ for category in CATEGORIES:
237
+ value = raw.get(category, defaults.name(category))
238
+ if not isinstance(value, str) or not value.strip():
239
+ raise ConfigError(f"{where}: `{category}` must name a Linear workflow state")
240
+ values[category] = value.strip()
241
+ return StatusNames(**values)
242
+
243
+
244
+ def _table(data: dict[str, Any], key: str, source: str) -> dict[str, Any]:
245
+ value = data.get(key, {})
246
+ if not isinstance(value, dict):
247
+ raise ConfigError(f"{source}: `{key}` must be a [{key}] table")
248
+ return value
249
+
250
+
251
+ def _check_keys(table: dict[str, Any], allowed: set[str], where: str) -> None:
252
+ unknown = sorted(set(table) - allowed)
253
+ if unknown:
254
+ names = ", ".join(f"`{k}`" for k in unknown)
255
+ raise ConfigError(f"{where}: unknown setting {names}")
256
+
257
+
258
+ def _optional_str(table: dict[str, Any], key: str, where: str) -> str | None:
259
+ value = table.get(key)
260
+ if value is None:
261
+ return None
262
+ if not isinstance(value, str) or not value.strip():
263
+ raise ConfigError(f"{where}: `{key}` must be a non-empty string")
264
+ return value.strip()
265
+
266
+
267
+ def _bool(table: dict[str, Any], key: str, default: bool, where: str) -> bool:
268
+ value = table.get(key, default)
269
+ if not isinstance(value, bool):
270
+ raise ConfigError(f"{where}: `{key}` must be true or false")
271
+ return value