repocast 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.
- repocast/__init__.py +2 -0
- repocast/__main__.py +3 -0
- repocast/backends.py +178 -0
- repocast/cli.py +212 -0
- repocast/config.py +100 -0
- repocast/diff.py +90 -0
- repocast/fmt.py +233 -0
- repocast/gate.py +97 -0
- repocast/model.py +111 -0
- repocast/numbers.py +96 -0
- repocast/oauth.py +42 -0
- repocast/runner.py +262 -0
- repocast/weight.py +86 -0
- repocast-0.1.0.dist-info/METADATA +258 -0
- repocast-0.1.0.dist-info/RECORD +19 -0
- repocast-0.1.0.dist-info/WHEEL +5 -0
- repocast-0.1.0.dist-info/entry_points.txt +2 -0
- repocast-0.1.0.dist-info/licenses/LICENSE +21 -0
- repocast-0.1.0.dist-info/top_level.txt +1 -0
repocast/__init__.py
ADDED
repocast/__main__.py
ADDED
repocast/backends.py
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""Posting backends.
|
|
2
|
+
|
|
3
|
+
x X API v2: POST /2/tweets (OAuth 1.0a user context), threads chained with reply.in_reply_to_tweet_id.
|
|
4
|
+
Before the first post of a run it calls GET /2/users/me and refuses to post unless the token belongs to the
|
|
5
|
+
configured account.
|
|
6
|
+
outbox Never posts: appends to an outbox (JSON Lines + a JSON Feed + plain text) for a human or another tool.
|
|
7
|
+
dry-run Never posts: prints what would be posted.
|
|
8
|
+
plugin "package.module:ClassName" implementing the Backend interface below.
|
|
9
|
+
|
|
10
|
+
Every backend's post_thread() returns the list of post ids, or raises PostError(definite=..., ids=[...]).
|
|
11
|
+
definite=True means the server clearly refused (nothing was created for the failing post); definite=False means the
|
|
12
|
+
outcome is unknown (timeout, connection reset, 5xx) and the runner will NEVER retry it automatically.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import importlib
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
import urllib.error
|
|
20
|
+
import urllib.request
|
|
21
|
+
from datetime import datetime, timezone
|
|
22
|
+
|
|
23
|
+
from . import __version__, oauth
|
|
24
|
+
from .weight import weighted_len
|
|
25
|
+
|
|
26
|
+
API_BASE = "https://api.x.com/2"
|
|
27
|
+
CRED_ENV = ("X_API_KEY", "X_API_SECRET", "X_ACCESS_TOKEN", "X_ACCESS_TOKEN_SECRET")
|
|
28
|
+
USER_AGENT = f"repocast/{__version__}"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class PostError(Exception):
|
|
32
|
+
def __init__(self, msg: str, definite: bool, ids: list[str] | None = None):
|
|
33
|
+
super().__init__(msg)
|
|
34
|
+
self.definite, self.ids = definite, list(ids or [])
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class AccountMismatch(Exception):
|
|
38
|
+
"""The credentials do not belong to the configured account (or it could not be verified). Nothing is posted."""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class Backend:
|
|
42
|
+
name = "base"
|
|
43
|
+
live = False # True only for backends that publish somewhere public
|
|
44
|
+
|
|
45
|
+
def verify(self) -> str:
|
|
46
|
+
"""Check the backend may post; return a description. Raise AccountMismatch to abort the run."""
|
|
47
|
+
return self.name
|
|
48
|
+
|
|
49
|
+
def post_thread(self, posts: list[str], item: dict) -> list[str]:
|
|
50
|
+
raise NotImplementedError
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class DryRunBackend(Backend):
|
|
54
|
+
name = "dry-run"
|
|
55
|
+
|
|
56
|
+
def __init__(self, log=print, **_):
|
|
57
|
+
self.log = log
|
|
58
|
+
|
|
59
|
+
def post_thread(self, posts, item):
|
|
60
|
+
for i, p in enumerate(posts, 1):
|
|
61
|
+
self.log(f"--- {item['short']} post {i}/{len(posts)} ({weighted_len(p)}/280) ---\n{p}")
|
|
62
|
+
return [f"dry-run-{item['short']}-{i}" for i in range(1, len(posts) + 1)]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class OutboxBackend(Backend):
|
|
66
|
+
"""Writes <dir>/outbox.jsonl (append), <dir>/outbox.json (JSON Feed 1.1 of the last 100) and <dir>/outbox.txt."""
|
|
67
|
+
name = "outbox"
|
|
68
|
+
|
|
69
|
+
def __init__(self, path: str = "outbox", **_):
|
|
70
|
+
self.dir = path
|
|
71
|
+
|
|
72
|
+
def post_thread(self, posts, item):
|
|
73
|
+
os.makedirs(self.dir, exist_ok=True)
|
|
74
|
+
now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
75
|
+
rec = {"id": item["id"], "short": item["short"], "date": item["date"], "commit_url": item.get("commit_url"),
|
|
76
|
+
"queued_at": now, "posts": posts, "weighted_lengths": [weighted_len(p) for p in posts]}
|
|
77
|
+
with open(os.path.join(self.dir, "outbox.jsonl"), "a", encoding="utf-8") as f:
|
|
78
|
+
f.write(json.dumps(rec, ensure_ascii=False) + "\n")
|
|
79
|
+
with open(os.path.join(self.dir, "outbox.jsonl"), encoding="utf-8") as f:
|
|
80
|
+
recs = [json.loads(ln) for ln in f if ln.strip()][-100:]
|
|
81
|
+
feed = {"version": "https://jsonfeed.org/version/1.1", "title": "repocast outbox",
|
|
82
|
+
"items": [{"id": r["id"], "url": r.get("commit_url"), "date_published": r["queued_at"],
|
|
83
|
+
"content_text": "\n\n---\n\n".join(r["posts"]), "_repocast": {"posts": r["posts"]}}
|
|
84
|
+
for r in reversed(recs)]}
|
|
85
|
+
tmp = os.path.join(self.dir, "outbox.json.tmp")
|
|
86
|
+
with open(tmp, "w", encoding="utf-8") as f:
|
|
87
|
+
json.dump(feed, f, indent=1, ensure_ascii=False)
|
|
88
|
+
os.replace(tmp, os.path.join(self.dir, "outbox.json"))
|
|
89
|
+
with open(os.path.join(self.dir, "outbox.txt"), "a", encoding="utf-8") as f:
|
|
90
|
+
f.write(f"=== {item['short']} ({item['date']}) ===\n" + "\n--- reply ---\n".join(posts) + "\n\n")
|
|
91
|
+
return [f"outbox-{item['short']}-{i}" for i in range(1, len(posts) + 1)]
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _http_json(req: urllib.request.Request, timeout: int) -> dict:
|
|
95
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
96
|
+
return json.load(resp)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class XApiBackend(Backend):
|
|
100
|
+
name = "x"
|
|
101
|
+
live = True
|
|
102
|
+
|
|
103
|
+
def __init__(self, creds: dict, account: str | None = None, account_id: str | None = None,
|
|
104
|
+
api_base: str = API_BASE, timeout: int = 30, http=_http_json, **_):
|
|
105
|
+
missing = [k for k in CRED_ENV if not (creds.get(k) or "").strip()]
|
|
106
|
+
if missing:
|
|
107
|
+
raise ValueError("missing credentials: " + ", ".join(missing))
|
|
108
|
+
if not account and not account_id:
|
|
109
|
+
raise ValueError("the x backend needs account (username) or account_id so it can refuse to post elsewhere")
|
|
110
|
+
self.creds, self.account, self.account_id = creds, (account or "").lstrip("@"), account_id
|
|
111
|
+
self.api, self.timeout, self.http = api_base.rstrip("/"), timeout, http
|
|
112
|
+
self.verified: dict | None = None
|
|
113
|
+
|
|
114
|
+
def _auth(self, method: str, url: str) -> str:
|
|
115
|
+
c = self.creds
|
|
116
|
+
return oauth.header(method, url, c["X_API_KEY"], c["X_API_SECRET"], c["X_ACCESS_TOKEN"], c["X_ACCESS_TOKEN_SECRET"])
|
|
117
|
+
|
|
118
|
+
def verify(self) -> str:
|
|
119
|
+
url = f"{self.api}/users/me"
|
|
120
|
+
req = urllib.request.Request(url, method="GET", headers={"Authorization": self._auth("GET", url), "User-Agent": USER_AGENT})
|
|
121
|
+
try:
|
|
122
|
+
data = self.http(req, self.timeout)["data"]
|
|
123
|
+
except Exception as e: # noqa: BLE001 - any failure means we cannot prove who we are
|
|
124
|
+
raise AccountMismatch(f"could not verify the account via GET /2/users/me: {type(e).__name__}: {e}")
|
|
125
|
+
uname, uid = str(data.get("username", "")), str(data.get("id", ""))
|
|
126
|
+
if self.account and uname.lower() != self.account.lower():
|
|
127
|
+
raise AccountMismatch(f"credentials belong to @{uname}, but the configured account is @{self.account}")
|
|
128
|
+
if self.account_id and uid != str(self.account_id):
|
|
129
|
+
raise AccountMismatch(f"credentials belong to user id {uid}, but the configured account_id is {self.account_id}")
|
|
130
|
+
self.verified = {"username": uname, "id": uid}
|
|
131
|
+
return f"@{uname} ({uid})"
|
|
132
|
+
|
|
133
|
+
def create_post(self, text: str, reply_to: str | None) -> str:
|
|
134
|
+
if self.verified is None:
|
|
135
|
+
raise AccountMismatch("refusing to post: account not verified (call verify() first)")
|
|
136
|
+
url = f"{self.api}/tweets"
|
|
137
|
+
body = {"text": text}
|
|
138
|
+
if reply_to:
|
|
139
|
+
body["reply"] = {"in_reply_to_tweet_id": reply_to}
|
|
140
|
+
req = urllib.request.Request(url, data=json.dumps(body).encode(), method="POST", headers={
|
|
141
|
+
"Authorization": self._auth("POST", url), "Content-Type": "application/json", "User-Agent": USER_AGENT})
|
|
142
|
+
return str(self.http(req, self.timeout)["data"]["id"])
|
|
143
|
+
|
|
144
|
+
def post_thread(self, posts, item):
|
|
145
|
+
ids: list[str] = []
|
|
146
|
+
for text in posts:
|
|
147
|
+
try:
|
|
148
|
+
ids.append(self.create_post(text, ids[-1] if ids else None))
|
|
149
|
+
except AccountMismatch:
|
|
150
|
+
raise
|
|
151
|
+
except urllib.error.HTTPError as e:
|
|
152
|
+
detail = ""
|
|
153
|
+
try:
|
|
154
|
+
detail = e.read().decode("utf-8", "replace")[:300]
|
|
155
|
+
except Exception: # noqa: BLE001
|
|
156
|
+
pass
|
|
157
|
+
# 4xx: the API refused, nothing was created. 5xx: a gateway error can hide a created post -> unknown.
|
|
158
|
+
raise PostError(f"HTTP {e.code}: {detail}", definite=e.code < 500, ids=ids)
|
|
159
|
+
except Exception as e: # noqa: BLE001 - timeout / reset / bad response: the post may exist
|
|
160
|
+
raise PostError(f"{type(e).__name__}: {e}", definite=False, ids=ids)
|
|
161
|
+
return ids
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def make(kind: str, opts: dict, env: dict, log=print) -> Backend:
|
|
165
|
+
if kind == "dry-run":
|
|
166
|
+
return DryRunBackend(log=log)
|
|
167
|
+
if kind == "outbox":
|
|
168
|
+
return OutboxBackend(path=opts.get("outbox", "outbox"))
|
|
169
|
+
if kind == "x":
|
|
170
|
+
return XApiBackend({k: env.get(k, "") for k in CRED_ENV}, account=opts.get("account"),
|
|
171
|
+
account_id=opts.get("account_id"), api_base=opts.get("api_base", API_BASE))
|
|
172
|
+
if ":" in kind:
|
|
173
|
+
mod, cls = kind.split(":", 1)
|
|
174
|
+
obj = getattr(importlib.import_module(mod), cls)(**opts, env=env, log=log)
|
|
175
|
+
if not isinstance(obj, Backend) and not hasattr(obj, "post_thread"):
|
|
176
|
+
raise TypeError(f"{kind} does not implement post_thread()")
|
|
177
|
+
return obj
|
|
178
|
+
raise ValueError(f"unknown backend {kind!r} (x, outbox, dry-run or module:Class)")
|
repocast/cli.py
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
"""repocast command line."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
from datetime import datetime, timezone
|
|
9
|
+
|
|
10
|
+
from . import __version__, backends, config, model, runner
|
|
11
|
+
from .weight import weighted_len
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _cfg(a) -> dict:
|
|
15
|
+
over = {"input": {"path": getattr(a, "input", None), "kind": getattr(a, "kind", None), "posts": getattr(a, "posts", None)},
|
|
16
|
+
"post": {"backend": "dry-run" if getattr(a, "dry_run", False) else getattr(a, "backend", None),
|
|
17
|
+
"state": getattr(a, "state", None), "outbox": getattr(a, "outbox", None),
|
|
18
|
+
"account": getattr(a, "account", None)}}
|
|
19
|
+
if getattr(a, "mode", None):
|
|
20
|
+
over["format"] = {"mode": a.mode}
|
|
21
|
+
cfg = config.load(a.config if a.config else (("repocast.toml" if os.path.exists("repocast.toml") else None)), over)
|
|
22
|
+
if not cfg["input"]["path"]:
|
|
23
|
+
raise config.ConfigError("no input: pass --input or set [input].path")
|
|
24
|
+
return cfg
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _items(cfg) -> list[dict]:
|
|
28
|
+
return model.load(cfg["input"]["path"], cfg["input"]["kind"], cfg["input"]["posts"] or None)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def cmd_preview(a) -> int:
|
|
32
|
+
cfg = _cfg(a)
|
|
33
|
+
res = runner.preview(_items(cfg), cfg)
|
|
34
|
+
if a.last:
|
|
35
|
+
res = res[-a.last:]
|
|
36
|
+
if a.json:
|
|
37
|
+
print(json.dumps(res, indent=1, ensure_ascii=False))
|
|
38
|
+
return 0
|
|
39
|
+
ready = 0
|
|
40
|
+
for r in res:
|
|
41
|
+
if "posts" in r:
|
|
42
|
+
ready += 1
|
|
43
|
+
print(f"=== {r['short']} {r['date']} [{r['mode']}] READY ({', '.join(f'{n}/280' for n in r['weighted_lengths'])})")
|
|
44
|
+
print("\n--- reply ---\n".join(r["posts"]))
|
|
45
|
+
else:
|
|
46
|
+
print(f"=== {r['short']} {r['date']} SKIP: {r['skip']}")
|
|
47
|
+
print()
|
|
48
|
+
print(f"{ready} ready, {len(res) - ready} skipped")
|
|
49
|
+
return 0
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _summary(actions: list[dict]) -> None:
|
|
53
|
+
path = os.environ.get("GITHUB_STEP_SUMMARY")
|
|
54
|
+
if not path:
|
|
55
|
+
return
|
|
56
|
+
with open(path, "a", encoding="utf-8") as f:
|
|
57
|
+
f.write("### repocast\n\n")
|
|
58
|
+
for x in actions:
|
|
59
|
+
f.write(f"- **{x['action']}** {x.get('id', '')[:7]} {x.get('reason', '') or x.get('error', '')}\n")
|
|
60
|
+
for p in x.get("posts", []):
|
|
61
|
+
f.write("\n ```\n " + p.replace("\n", "\n ") + "\n ```\n")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _gh_output(**kv) -> None:
|
|
65
|
+
path = os.environ.get("GITHUB_OUTPUT")
|
|
66
|
+
if path:
|
|
67
|
+
with open(path, "a", encoding="utf-8") as f:
|
|
68
|
+
for k, v in kv.items():
|
|
69
|
+
f.write(f"{k}={v}\n")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def cmd_run(a) -> int:
|
|
73
|
+
cfg = _cfg(a)
|
|
74
|
+
p = cfg["post"]
|
|
75
|
+
items = _items(cfg)
|
|
76
|
+
env = dict(os.environ)
|
|
77
|
+
try:
|
|
78
|
+
be = backends.make(p["backend"], p, env)
|
|
79
|
+
except ValueError as e:
|
|
80
|
+
if p["backend"] == "x" and "missing credentials" in str(e):
|
|
81
|
+
print(f"[repocast] {e}: DRY RUN", file=sys.stderr)
|
|
82
|
+
be = backends.DryRunBackend()
|
|
83
|
+
else:
|
|
84
|
+
raise
|
|
85
|
+
now = datetime.fromisoformat(a.now.replace("Z", "+00:00")) if a.now else datetime.now(timezone.utc)
|
|
86
|
+
state, actions, changed = runner.run(items, cfg, runner.load_state(p["state"]), env, now, be)
|
|
87
|
+
if changed and state is not None:
|
|
88
|
+
runner.save_state(p["state"], state)
|
|
89
|
+
print(f"[repocast] state saved: {p['state']}")
|
|
90
|
+
_summary(actions)
|
|
91
|
+
count = lambda k: sum(1 for x in actions if x["action"] == k) # noqa: E731
|
|
92
|
+
_gh_output(posted=count("posted"), would_post=count("would_post"), skipped=count("skip"), deferred=count("defer"),
|
|
93
|
+
state_changed=str(changed).lower())
|
|
94
|
+
if any(x["action"] == "abort" for x in actions):
|
|
95
|
+
return 3
|
|
96
|
+
if any(x["action"] in ("failed", "uncertain", "partial") for x in actions):
|
|
97
|
+
return 1
|
|
98
|
+
return 0
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def cmd_count(a) -> int:
|
|
102
|
+
text = sys.stdin.read() if a.text == "-" else a.text
|
|
103
|
+
n = weighted_len(text)
|
|
104
|
+
print(f"{n}/280{'' if n <= 280 else ' TOO LONG'}")
|
|
105
|
+
return 0 if n <= 280 else 1
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def cmd_diff(a) -> int:
|
|
109
|
+
from .diff import diff_repo
|
|
110
|
+
data = diff_repo(a.repo, a.range, a.path or None, a.commit_url)
|
|
111
|
+
s = json.dumps(data, indent=1, ensure_ascii=False)
|
|
112
|
+
if a.out:
|
|
113
|
+
with open(a.out, "w", encoding="utf-8") as f:
|
|
114
|
+
f.write(s + "\n")
|
|
115
|
+
print(f"{len(data['items'])} commits with changes -> {a.out}")
|
|
116
|
+
else:
|
|
117
|
+
print(s)
|
|
118
|
+
return 0
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def cmd_verify(a) -> int:
|
|
122
|
+
cfg = _cfg(a) if (a.config or os.path.exists("repocast.toml")) else {"post": {"account": a.account, "account_id": None, "api_base": backends.API_BASE}}
|
|
123
|
+
acct = a.account or cfg["post"]["account"]
|
|
124
|
+
be = backends.XApiBackend({k: os.environ.get(k, "") for k in backends.CRED_ENV}, account=acct,
|
|
125
|
+
account_id=cfg["post"].get("account_id"), api_base=cfg["post"]["api_base"])
|
|
126
|
+
try:
|
|
127
|
+
print(f"OK: credentials belong to {be.verify()}")
|
|
128
|
+
return 0
|
|
129
|
+
except backends.AccountMismatch as e:
|
|
130
|
+
print(f"MISMATCH: {e}", file=sys.stderr)
|
|
131
|
+
return 3
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def cmd_forget(a) -> int:
|
|
135
|
+
st = runner.load_state(a.state)
|
|
136
|
+
if not st:
|
|
137
|
+
print("no state file", file=sys.stderr)
|
|
138
|
+
return 1
|
|
139
|
+
hits = [k for k in st["items"] if k.startswith(a.id)]
|
|
140
|
+
if len(hits) != 1:
|
|
141
|
+
print(f"{len(hits)} items match {a.id!r}", file=sys.stderr)
|
|
142
|
+
return 1
|
|
143
|
+
rec = st["items"].pop(hits[0])
|
|
144
|
+
if rec["status"] in ("posted", "partial", "uncertain") and not a.force:
|
|
145
|
+
print(f"{hits[0][:7]} is {rec['status']}: it may be public already. Check the account, then use --force.", file=sys.stderr)
|
|
146
|
+
return 1
|
|
147
|
+
runner.save_state(a.state, st)
|
|
148
|
+
print(f"forgot {hits[0][:7]} ({rec['status']}); it will be considered again on the next run")
|
|
149
|
+
return 0
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def main(argv: list[str] | None = None) -> int:
|
|
153
|
+
ap = argparse.ArgumentParser(prog="repocast", description="Turn a repo's changes into accurate X posts, and post them safely.")
|
|
154
|
+
ap.add_argument("--version", action="version", version=f"repocast {__version__}")
|
|
155
|
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
156
|
+
|
|
157
|
+
def common(sp):
|
|
158
|
+
sp.add_argument("--config", "-c", help="repocast.toml (default: ./repocast.toml if present)")
|
|
159
|
+
sp.add_argument("--input", "-i", help="knoblog feed.json / changes.json, or repocast items JSON")
|
|
160
|
+
sp.add_argument("--kind", choices=["auto", "knoblog-feed", "knoblog-changes", "items"])
|
|
161
|
+
sp.add_argument("--posts", help="knoblog posts.json (with --kind knoblog-changes)")
|
|
162
|
+
sp.add_argument("--mode", choices=config.MODES, help="prebuilt (knoblog's text), template, or auto")
|
|
163
|
+
|
|
164
|
+
sp = sub.add_parser("preview", help="compose + gate every item; ignores state and caps; posts nothing")
|
|
165
|
+
common(sp)
|
|
166
|
+
sp.add_argument("--json", action="store_true")
|
|
167
|
+
sp.add_argument("--last", type=int, help="only the newest N items")
|
|
168
|
+
sp.set_defaults(fn=cmd_preview)
|
|
169
|
+
|
|
170
|
+
sp = sub.add_parser("run", help="one bot run: dedup, caps, quiet hours, kill switch, then the backend")
|
|
171
|
+
common(sp)
|
|
172
|
+
sp.add_argument("--backend", help="x | outbox | dry-run | module:Class")
|
|
173
|
+
sp.add_argument("--dry-run", action="store_true", help="force the dry-run backend (records nothing)")
|
|
174
|
+
sp.add_argument("--state", help="state file (dedup)")
|
|
175
|
+
sp.add_argument("--outbox", help="outbox directory")
|
|
176
|
+
sp.add_argument("--account", help="X username the credentials must belong to")
|
|
177
|
+
sp.add_argument("--now", help=argparse.SUPPRESS)
|
|
178
|
+
sp.set_defaults(fn=cmd_run)
|
|
179
|
+
|
|
180
|
+
sp = sub.add_parser("count", help="X weighted length of a text ('-' = stdin)")
|
|
181
|
+
sp.add_argument("text")
|
|
182
|
+
sp.set_defaults(fn=cmd_count)
|
|
183
|
+
|
|
184
|
+
sp = sub.add_parser("diff", help="built-in simple diff: KEY = VALUE changes per commit -> items JSON")
|
|
185
|
+
sp.add_argument("--repo", default=".")
|
|
186
|
+
sp.add_argument("--range", required=True, help="e.g. HEAD~20..HEAD or v1.0..main")
|
|
187
|
+
sp.add_argument("--path", action="append", help="limit to these paths (repeatable)")
|
|
188
|
+
sp.add_argument("--commit-url", help="e.g. https://github.com/o/r/commit/{sha} (default: from origin)")
|
|
189
|
+
sp.add_argument("--out", "-o")
|
|
190
|
+
sp.set_defaults(fn=cmd_diff)
|
|
191
|
+
|
|
192
|
+
sp = sub.add_parser("verify-account", help="GET /2/users/me with the X_* credentials and compare to the account")
|
|
193
|
+
sp.add_argument("--config", "-c")
|
|
194
|
+
sp.add_argument("--account")
|
|
195
|
+
sp.set_defaults(fn=cmd_verify, input=None)
|
|
196
|
+
|
|
197
|
+
sp = sub.add_parser("forget", help="remove an item from the state file so it is considered again")
|
|
198
|
+
sp.add_argument("id")
|
|
199
|
+
sp.add_argument("--state", default=".repocast/state.json")
|
|
200
|
+
sp.add_argument("--force", action="store_true")
|
|
201
|
+
sp.set_defaults(fn=cmd_forget)
|
|
202
|
+
|
|
203
|
+
a = ap.parse_args(argv)
|
|
204
|
+
try:
|
|
205
|
+
return a.fn(a)
|
|
206
|
+
except (config.ConfigError, model.InputError, ValueError) as e:
|
|
207
|
+
print(f"repocast: error: {e}", file=sys.stderr)
|
|
208
|
+
return 2
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
if __name__ == "__main__":
|
|
212
|
+
sys.exit(main())
|
repocast/config.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""repocast.toml loading and validation (stdlib tomllib)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
try:
|
|
8
|
+
import tomllib
|
|
9
|
+
except ModuleNotFoundError: # Python 3.10
|
|
10
|
+
import tomli as tomllib # type: ignore
|
|
11
|
+
|
|
12
|
+
from .fmt import TemplateError, validate_templates
|
|
13
|
+
|
|
14
|
+
DEFAULTS = {
|
|
15
|
+
"input": {"path": "", "kind": "auto", "posts": ""},
|
|
16
|
+
"format": {"mode": "auto", "project": "", "date_format": "%b %-d", "disclaimer": "", "max_lines": 3,
|
|
17
|
+
"max_posts": 4, "max_line_weight": 140, "highlight": [], "name_style": "safe", "display_names": {}, "templates": {},
|
|
18
|
+
"trusted_text": [], "allow_mentions": False, "allow_hashtags": False, "honor_skips": True},
|
|
19
|
+
"filter": {"kinds": ["changed", "added", "removed"], "include": "", "exclude": "", "files": "",
|
|
20
|
+
"exclude_files": "", "min_pct": 0.0},
|
|
21
|
+
"post": {"backend": "dry-run", "account": "", "account_id": "", "outbox": "outbox",
|
|
22
|
+
"state": ".repocast/state.json", "max_per_run": 1, "max_per_day": 3, "stale_after_hours": 72,
|
|
23
|
+
"max_attempts": 2, "quiet_hours": "", "timezone": "UTC", "live_env": "REPOCAST_LIVE",
|
|
24
|
+
"kill_switch_env": "REPOCAST_KILL_SWITCH", "api_base": "https://api.x.com/2"},
|
|
25
|
+
}
|
|
26
|
+
MODES = ("auto", "prebuilt", "template")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ConfigError(ValueError):
|
|
30
|
+
pass
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _merge(base: dict, over: dict, where: str) -> dict:
|
|
34
|
+
out = {}
|
|
35
|
+
for sec, vals in base.items():
|
|
36
|
+
given = over.get(sec, {})
|
|
37
|
+
if not isinstance(given, dict):
|
|
38
|
+
raise ConfigError(f"[{sec}] must be a table")
|
|
39
|
+
unknown = set(given) - set(vals)
|
|
40
|
+
if unknown:
|
|
41
|
+
raise ConfigError(f"{where}: unknown key [{sec}].{sorted(unknown)[0]} (known: {', '.join(sorted(vals))})")
|
|
42
|
+
out[sec] = {**vals, **given}
|
|
43
|
+
unknown = set(over) - set(base)
|
|
44
|
+
if unknown:
|
|
45
|
+
raise ConfigError(f"{where}: unknown section [{sorted(unknown)[0]}]")
|
|
46
|
+
return out
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def validate(cfg: dict) -> dict:
|
|
50
|
+
f, p, flt = cfg["format"], cfg["post"], cfg["filter"]
|
|
51
|
+
if f["mode"] not in MODES:
|
|
52
|
+
raise ConfigError(f"[format].mode must be one of {MODES}")
|
|
53
|
+
try:
|
|
54
|
+
validate_templates(f["templates"])
|
|
55
|
+
except TemplateError as e:
|
|
56
|
+
raise ConfigError(str(e))
|
|
57
|
+
for k in ("max_lines", "max_posts", "max_per_run", "max_per_day", "max_attempts"):
|
|
58
|
+
sec = f if k in f else p
|
|
59
|
+
if not isinstance(sec[k], int) or sec[k] < 0:
|
|
60
|
+
raise ConfigError(f"{k} must be a non-negative integer")
|
|
61
|
+
if not 1 <= f["max_posts"] <= 25:
|
|
62
|
+
raise ConfigError("max_posts must be between 1 and 25")
|
|
63
|
+
for k in ("include", "exclude", "files", "exclude_files"):
|
|
64
|
+
try:
|
|
65
|
+
re.compile(flt[k])
|
|
66
|
+
except re.error as e:
|
|
67
|
+
raise ConfigError(f"[filter].{k}: bad regex ({e})")
|
|
68
|
+
if p["quiet_hours"] and not re.fullmatch(r"\d{1,2}:\d{2}-\d{1,2}:\d{2}", p["quiet_hours"]):
|
|
69
|
+
raise ConfigError("[post].quiet_hours must look like '22:00-07:00'")
|
|
70
|
+
try:
|
|
71
|
+
from zoneinfo import ZoneInfo
|
|
72
|
+
ZoneInfo(p["timezone"])
|
|
73
|
+
except Exception: # noqa: BLE001
|
|
74
|
+
raise ConfigError(f"[post].timezone: unknown time zone {p['timezone']!r}")
|
|
75
|
+
if p["backend"] == "x" and not (p["account"] or p["account_id"]):
|
|
76
|
+
raise ConfigError("[post].backend = 'x' needs account (the @username to post as) or account_id")
|
|
77
|
+
return cfg
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def load(path: str | None, overrides: dict | None = None) -> dict:
|
|
81
|
+
raw: dict = {}
|
|
82
|
+
base_dir = os.getcwd()
|
|
83
|
+
if path:
|
|
84
|
+
try:
|
|
85
|
+
with open(path, "rb") as fh:
|
|
86
|
+
raw = tomllib.load(fh)
|
|
87
|
+
except FileNotFoundError:
|
|
88
|
+
raise ConfigError(f"config not found: {path}")
|
|
89
|
+
except tomllib.TOMLDecodeError as e:
|
|
90
|
+
raise ConfigError(f"{path}: {e}")
|
|
91
|
+
base_dir = os.path.dirname(os.path.abspath(path))
|
|
92
|
+
cfg = _merge(DEFAULTS, raw, path or "config")
|
|
93
|
+
for sec, vals in (overrides or {}).items():
|
|
94
|
+
cfg[sec].update({k: v for k, v in vals.items() if v is not None})
|
|
95
|
+
# relative paths in a config file are relative to that file
|
|
96
|
+
for sec, key in (("input", "path"), ("input", "posts"), ("post", "state"), ("post", "outbox")):
|
|
97
|
+
v = cfg[sec][key]
|
|
98
|
+
if v and path and not os.path.isabs(v) and not (overrides or {}).get(sec, {}).get(key):
|
|
99
|
+
cfg[sec][key] = os.path.normpath(os.path.join(base_dir, v))
|
|
100
|
+
return validate(cfg)
|
repocast/diff.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""A deliberately simple built-in repo diff, for repos without knoblog.
|
|
2
|
+
|
|
3
|
+
For each commit in a range it pairs removed/added lines that look like `KEY = VALUE` / `KEY: VALUE` /
|
|
4
|
+
`#define KEY VALUE` / `const KEY: T = VALUE;` in the same file. A key that appears more than once among a file's
|
|
5
|
+
removed or added lines is ambiguous and dropped (no guessing). For nested YAML/JSON, Python/C++ expressions,
|
|
6
|
+
macros or parameter files with structure, use knoblog, which understands them.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
import subprocess
|
|
12
|
+
|
|
13
|
+
VALUE = r'(?P<v>[-+]?(?:\d[\d_\']*\.?\d*|\.\d+)(?:[eE][-+]?\d+)?(?:_?[a-zA-Z]\w{0,4})?|true|false|True|False|"[^"\n]{0,80}"|\'[^\'\n]{0,80}\')'
|
|
14
|
+
KV = re.compile(
|
|
15
|
+
r'^\s*(?:#define\s+)?(?:(?:export|pub(?:\(crate\))?|const|static|let|var|final|constexpr|readonly|private|public|protected|inline)\s+)*'
|
|
16
|
+
r'(?:[\w:<>\[\]]+\s+)??["\']?(?P<k>[A-Za-z_][\w.\-]*)["\']?\s*(?::\s*[\w:<>\[\]]+\s*)?(?:=|:|\s)\s*' + VALUE +
|
|
17
|
+
r'\s*[,;]?\s*(?:(?://|#|--).*)?$')
|
|
18
|
+
TYPES = {"int", "float", "double", "bool", "auto", "uint8_t", "uint16_t", "uint32_t", "int32_t", "size_t", "string"}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def parse_line(line: str) -> tuple[str, str] | None:
|
|
22
|
+
m = KV.match(line)
|
|
23
|
+
if not m or m.group("k") in TYPES:
|
|
24
|
+
return None
|
|
25
|
+
return m.group("k"), m.group("v").strip("'\"") if m.group("v")[0] in "'\"" else m.group("v")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _git(repo: str, *args: str) -> str:
|
|
29
|
+
return subprocess.run(["git", "-C", repo, *args], check=True, capture_output=True, text=True).stdout
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def file_changes(lines: list[str]) -> list[dict]:
|
|
33
|
+
"""lines = one file's unified diff body (lines starting with + / -)."""
|
|
34
|
+
rem: dict[str, list[str]] = {}
|
|
35
|
+
add: dict[str, list[str]] = {}
|
|
36
|
+
for ln in lines:
|
|
37
|
+
if ln.startswith(("+++", "---")) or not ln[:1] in "+-":
|
|
38
|
+
continue
|
|
39
|
+
kv = parse_line(ln[1:])
|
|
40
|
+
if kv:
|
|
41
|
+
(add if ln[0] == "+" else rem).setdefault(kv[0], []).append(kv[1])
|
|
42
|
+
out = []
|
|
43
|
+
for k in sorted(set(rem) | set(add)):
|
|
44
|
+
r, a = rem.get(k, []), add.get(k, [])
|
|
45
|
+
if len(r) > 1 or len(a) > 1:
|
|
46
|
+
continue # ambiguous
|
|
47
|
+
if r and a:
|
|
48
|
+
if r[0] != a[0]:
|
|
49
|
+
out.append({"name": k, "kind": "changed", "old": r[0], "new": a[0]})
|
|
50
|
+
elif a:
|
|
51
|
+
out.append({"name": k, "kind": "added", "old": None, "new": a[0]})
|
|
52
|
+
else:
|
|
53
|
+
out.append({"name": k, "kind": "removed", "old": r[0], "new": None})
|
|
54
|
+
return out
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def remote_commit_url(repo: str) -> str | None:
|
|
58
|
+
try:
|
|
59
|
+
url = _git(repo, "remote", "get-url", "origin").strip()
|
|
60
|
+
except subprocess.CalledProcessError:
|
|
61
|
+
return None
|
|
62
|
+
m = re.match(r"(?:https://|git@)github\.com[:/](.+?)(?:\.git)?$", url)
|
|
63
|
+
return f"https://github.com/{m.group(1)}/commit/{{sha}}" if m else None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def diff_repo(repo: str, rev_range: str, paths: list[str] | None = None, commit_url: str | None = None) -> dict:
|
|
67
|
+
"""{'generator': 'repocast diff', 'items': [...]} for every commit in `rev_range` with at least one change."""
|
|
68
|
+
commit_url = commit_url or remote_commit_url(repo)
|
|
69
|
+
shas = _git(repo, "rev-list", "--reverse", "--no-merges", rev_range).split()
|
|
70
|
+
items = []
|
|
71
|
+
for sha in shas:
|
|
72
|
+
meta = _git(repo, "show", "-s", "--format=%cI%x00%s", sha).strip().split("\x00")
|
|
73
|
+
out = _git(repo, "show", "--format=", "-U0", "--no-color", "--no-ext-diff", sha, "--", *(paths or []))
|
|
74
|
+
changes, cur, buf = [], None, []
|
|
75
|
+
|
|
76
|
+
def flush():
|
|
77
|
+
if cur:
|
|
78
|
+
for c in file_changes(buf):
|
|
79
|
+
changes.append({**c, "file": cur, "commit": sha})
|
|
80
|
+
for ln in out.splitlines():
|
|
81
|
+
if ln.startswith("diff --git "):
|
|
82
|
+
flush()
|
|
83
|
+
cur, buf = ln.split(" b/", 1)[-1], []
|
|
84
|
+
else:
|
|
85
|
+
buf.append(ln)
|
|
86
|
+
flush()
|
|
87
|
+
if changes:
|
|
88
|
+
items.append({"id": sha, "short": sha[:7], "date": meta[0], "title": meta[1] if len(meta) > 1 else None,
|
|
89
|
+
"commit_url": commit_url.format(sha=sha) if commit_url else None, "changes": changes})
|
|
90
|
+
return {"generator": "repocast diff", "repo": repo, "range": rev_range, "items": items}
|