repocast 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.
- repocast-0.1.0/LICENSE +21 -0
- repocast-0.1.0/PKG-INFO +258 -0
- repocast-0.1.0/README.md +237 -0
- repocast-0.1.0/pyproject.toml +37 -0
- repocast-0.1.0/repocast/__init__.py +2 -0
- repocast-0.1.0/repocast/__main__.py +3 -0
- repocast-0.1.0/repocast/backends.py +178 -0
- repocast-0.1.0/repocast/cli.py +212 -0
- repocast-0.1.0/repocast/config.py +100 -0
- repocast-0.1.0/repocast/diff.py +90 -0
- repocast-0.1.0/repocast/fmt.py +233 -0
- repocast-0.1.0/repocast/gate.py +97 -0
- repocast-0.1.0/repocast/model.py +111 -0
- repocast-0.1.0/repocast/numbers.py +96 -0
- repocast-0.1.0/repocast/oauth.py +42 -0
- repocast-0.1.0/repocast/runner.py +262 -0
- repocast-0.1.0/repocast/weight.py +86 -0
- repocast-0.1.0/repocast.egg-info/PKG-INFO +258 -0
- repocast-0.1.0/repocast.egg-info/SOURCES.txt +30 -0
- repocast-0.1.0/repocast.egg-info/dependency_links.txt +1 -0
- repocast-0.1.0/repocast.egg-info/entry_points.txt +2 -0
- repocast-0.1.0/repocast.egg-info/requires.txt +6 -0
- repocast-0.1.0/repocast.egg-info/top_level.txt +1 -0
- repocast-0.1.0/setup.cfg +4 -0
- repocast-0.1.0/tests/test_backends.py +115 -0
- repocast-0.1.0/tests/test_examples.py +89 -0
- repocast-0.1.0/tests/test_fmt.py +74 -0
- repocast-0.1.0/tests/test_gate.py +98 -0
- repocast-0.1.0/tests/test_inputs.py +86 -0
- repocast-0.1.0/tests/test_oauth.py +27 -0
- repocast-0.1.0/tests/test_runner.py +223 -0
- repocast-0.1.0/tests/test_weight.py +39 -0
repocast-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Joshua Almeida
|
|
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.
|
repocast-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: repocast
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Turn any repo's changes into accurate, ready-to-post X posts, and post them safely (OAuth 1.0a, threads, dedup, caps, kill switch).
|
|
5
|
+
Author: Joshua Almeida
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/fitzyracing1/repocast
|
|
8
|
+
Project-URL: Issues, https://github.com/fitzyracing1/repocast/issues
|
|
9
|
+
Keywords: x,twitter,bot,changelog,github-action,release-notes,automation
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Topic :: Software Development :: Build Tools
|
|
13
|
+
Classifier: Topic :: Communications
|
|
14
|
+
Requires-Python: >=3.10
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
License-File: LICENSE
|
|
17
|
+
Requires-Dist: tomli>=2; python_version < "3.11"
|
|
18
|
+
Provides-Extra: knoblog
|
|
19
|
+
Requires-Dist: knoblog>=0.1; extra == "knoblog"
|
|
20
|
+
Dynamic: license-file
|
|
21
|
+
|
|
22
|
+
# repocast
|
|
23
|
+
|
|
24
|
+
**Turn any repo's changes into accurate, ready-to-post X posts, and post them safely.**
|
|
25
|
+
|
|
26
|
+
repocast is for people who build bots that post on X about code changes, such as changelog bots, "parameter
|
|
27
|
+
watch" accounts, or release announcers. You give it structured change data. It writes posts or threads that fit X's
|
|
28
|
+
weighted 280-character limit and checks that every number in them comes from the data. It then posts them through
|
|
29
|
+
the X API v2, or writes them to an outbox, or only prints them. The guard rails a bot needs are built in: dedup,
|
|
30
|
+
caps, a kill switch, quiet hours, an account check, and no retries when the outcome is unknown.
|
|
31
|
+
|
|
32
|
+
Pure Python standard library (3.10+); no third-party dependency at runtime.
|
|
33
|
+
|
|
34
|
+
```
|
|
35
|
+
knoblog feed.json / changes.json ─┐
|
|
36
|
+
repocast diff (built-in, simple) ─┼─▶ compose ─▶ accuracy gate ─▶ dedup/caps/switches ─▶ X API v2 │ outbox │ dry run
|
|
37
|
+
your own items JSON ─┘ (prebuilt text or {placeholder} templates)
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
It grew out of the [@XAlgoChangelog](https://github.com/fitzyracing1/x-algorithm-changelog) bot. With the config in
|
|
41
|
+
`examples/x-algorithm/` it reproduces that bot's post text exactly, for all 23 post-worthy commits (this is a test).
|
|
42
|
+
|
|
43
|
+
## Quickstart (CLI)
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
pip install repocast # or: pip install "repocast[knoblog]"
|
|
47
|
+
repocast count "🤖 Nav2 defaults changed → https://example.com" # 51/280
|
|
48
|
+
|
|
49
|
+
# 1) change data: knoblog's output (preferred) or the built-in diff
|
|
50
|
+
knoblog run --config knoblog.yml --out site # writes site/feed.json, changes.json, posts.json
|
|
51
|
+
# or
|
|
52
|
+
repocast diff --repo . --range v1.4.0..HEAD -o items.json
|
|
53
|
+
|
|
54
|
+
# 2) look at what would be posted (no state, no caps, posts nothing)
|
|
55
|
+
repocast preview -i site/feed.json
|
|
56
|
+
|
|
57
|
+
# 3) a real run. Without REPOCAST_LIVE=true this is still a dry run and records nothing.
|
|
58
|
+
repocast run -c repocast.toml
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
`repocast.toml`:
|
|
62
|
+
|
|
63
|
+
```toml
|
|
64
|
+
[input]
|
|
65
|
+
path = "site/feed.json" # knoblog feed.json / changes.json (+ posts.json), or repocast items JSON
|
|
66
|
+
|
|
67
|
+
[format]
|
|
68
|
+
mode = "auto" # prebuilt (knoblog's text) | template | auto (prebuilt when available)
|
|
69
|
+
project = "MyRobot"
|
|
70
|
+
disclaimer = "Values are repo defaults."
|
|
71
|
+
max_lines = 3 # change lines after the headline
|
|
72
|
+
max_posts = 4 # longest thread allowed
|
|
73
|
+
|
|
74
|
+
[format.templates] # optional; plain {placeholders}, no template engine
|
|
75
|
+
header = "🤖 {project} update ({date}): {lead}"
|
|
76
|
+
line = "{name} {old} → {new} ({change})"
|
|
77
|
+
|
|
78
|
+
[filter]
|
|
79
|
+
exclude_files = "(^|/)tests?/"
|
|
80
|
+
|
|
81
|
+
[post]
|
|
82
|
+
backend = "x" # x | outbox | dry-run | package.module:Class
|
|
83
|
+
account = "MyRobotChanges" # the ONLY account this bot may post as (checked via GET /2/users/me)
|
|
84
|
+
state = ".repocast/state.json" # dedup state: commit it or cache it between runs
|
|
85
|
+
max_per_run = 1
|
|
86
|
+
max_per_day = 3
|
|
87
|
+
stale_after_hours = 72
|
|
88
|
+
quiet_hours = "22:00-07:00"
|
|
89
|
+
timezone = "America/New_York"
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## Quickstart (GitHub Action)
|
|
93
|
+
|
|
94
|
+
```yaml
|
|
95
|
+
- uses: fitzyracing1/knoblog@v0 # or any step that writes change data
|
|
96
|
+
with: { config: knoblog.yml, out: site }
|
|
97
|
+
- id: rc
|
|
98
|
+
uses: fitzyracing1/repocast@v0
|
|
99
|
+
with: { config: repocast.toml }
|
|
100
|
+
env:
|
|
101
|
+
REPOCAST_LIVE: ${{ vars.REPOCAST_LIVE }} # 'true' to actually post
|
|
102
|
+
REPOCAST_KILL_SWITCH: ${{ vars.REPOCAST_KILL_SWITCH }} # 'true' stops everything
|
|
103
|
+
X_API_KEY: ${{ secrets.X_API_KEY }}
|
|
104
|
+
X_API_SECRET: ${{ secrets.X_API_SECRET }}
|
|
105
|
+
X_ACCESS_TOKEN: ${{ secrets.X_ACCESS_TOKEN }}
|
|
106
|
+
X_ACCESS_TOKEN_SECRET: ${{ secrets.X_ACCESS_TOKEN_SECRET }}
|
|
107
|
+
# then commit .repocast/state.json when steps.rc.outputs.state_changed == 'true'
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
A complete scheduled workflow is in [`examples/workflows/post-changes.yml`](examples/workflows/post-changes.yml). It
|
|
111
|
+
covers concurrency, the state commit, and the switches. Action outputs: `posted`, `would_post`, `state_changed`.
|
|
112
|
+
Each run also writes a step summary showing every post.
|
|
113
|
+
|
|
114
|
+
## Input
|
|
115
|
+
|
|
116
|
+
| Source | What repocast reads |
|
|
117
|
+
| --- | --- |
|
|
118
|
+
| knoblog `feed.json` (preferred) | per commit: the change records **and** knoblog's ready-made post text / skip reason |
|
|
119
|
+
| knoblog `changes.json` (+ `posts.json` next to it) | flat change records grouped by commit; prebuilt posts if `posts.json` is there |
|
|
120
|
+
| `repocast diff` | `KEY = VALUE`, `KEY: VALUE`, `#define KEY VALUE` and `const KEY: T = VALUE;` changes per commit. A key that appears twice in one file is dropped as ambiguous. Use knoblog for nested YAML, parameter tables, macros and expressions. |
|
|
121
|
+
| your own JSON | `{"items": [{"id", "date", "commit_url", "url", "changes": [{"name", "old", "new", "kind", "file"}], "posts"?}]}` |
|
|
122
|
+
|
|
123
|
+
`kind` is `changed`, `added`, `removed`, `moved` or `renamed`. knoblog is an optional extra
|
|
124
|
+
(`pip install "repocast[knoblog]"`). repocast only reads knoblog's files, so either tool can run in a separate step.
|
|
125
|
+
|
|
126
|
+
## Formatting
|
|
127
|
+
|
|
128
|
+
* **X weighted counting** (twitter-text v3 rules). Every URL counts as 23. Domain-like tokens such as `params.rs` also
|
|
129
|
+
count as 23, and the gate rejects them because X would auto-link them. CJK characters and most symbols (`→`, `▸`)
|
|
130
|
+
weigh 2. An emoji weighs 2 whatever its length: ZWJ families, skin tones, flags and keycaps included. Text is NFC
|
|
131
|
+
normalised. Run `repocast count "…"` to check a text.
|
|
132
|
+
* **Threads.** The headline goes in the first post, then whole change lines, then the footer on the last post. The
|
|
133
|
+
footer is never left alone in a tiny reply. Each reply is chained to the post before it with
|
|
134
|
+
`reply.in_reply_to_tweet_id`.
|
|
135
|
+
* **Templates** use plain `{placeholders}`, written `{{`/`}}` for literal braces. An unknown placeholder fails when
|
|
136
|
+
the config loads, not at 3 a.m. A line whose placeholders are all empty is dropped, so `{url}` disappears when
|
|
137
|
+
there is no details page.
|
|
138
|
+
|
|
139
|
+
| template | placeholders |
|
|
140
|
+
| --- | --- |
|
|
141
|
+
| `header`, `footer`, `more_line` | `{project} {date} {count} {short} {id} {title} {commit_url} {url} {disclaimer} {more} {lead}` |
|
|
142
|
+
| `line`, `text_line`, `flag_line`, `added_line`, `removed_line`, `other_line` | `{name} {old} {new} {change} {pct} {ratio} {file} {kind} {type}` + `{project} {date} {short} {commit_url} {url}` |
|
|
143
|
+
| `bullet` | (literal prefix for each change line, default `- `) |
|
|
144
|
+
|
|
145
|
+
`{change}` reads like `25% higher`, `7.2x lower` or `sign flipped`. Highlighted names (`highlight = [...]`) lead,
|
|
146
|
+
then the biggest relative changes. `name_style = "safe"` (the default) turns dotted names that X would link into
|
|
147
|
+
slash form. `display_names` maps raw names to friendly ones.
|
|
148
|
+
|
|
149
|
+
## The accuracy gate
|
|
150
|
+
|
|
151
|
+
A post is sent only if **every number in it traces back to the change data**. A number passes if it is one of these:
|
|
152
|
+
|
|
153
|
+
* an old or new value of one of the item's changes (`125,000` matches `125_000`);
|
|
154
|
+
* a correctly rounded quantity derived from one change: percent change, ratio, inverse ratio or difference. Unit
|
|
155
|
+
conversions (ms ↔ s ↔ min ↔ h ↔ d, KiB/MiB) count only when the parameter name suggests a unit, as in `timeout_ms`;
|
|
156
|
+
* a count no larger than the number of changes (`+3 more`), or part of the item's date;
|
|
157
|
+
* a number you typed into your own templates. With prebuilt text, numbers in `trusted_text` and the disclaimer
|
|
158
|
+
also pass.
|
|
159
|
+
|
|
160
|
+
The gate also rejects @mentions and #hashtags (unless allowed), domain-like tokens outside real links, any post
|
|
161
|
+
over 280 weighted characters, and threads longer than `max_posts`. A failed check never goes to X. The item is
|
|
162
|
+
skipped and the reason is recorded:
|
|
163
|
+
|
|
164
|
+
```
|
|
165
|
+
=== 1a2b3c4 SKIP: gate: unverified number '50' in post 1: not in the change data
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
Prebuilt text, such as knoblog's, goes through the same gate, so a bug or a hand edit upstream cannot put a wrong
|
|
169
|
+
number on X.
|
|
170
|
+
|
|
171
|
+
## Posting backends
|
|
172
|
+
|
|
173
|
+
| backend | what it does |
|
|
174
|
+
| --- | --- |
|
|
175
|
+
| `x` | X API v2 `POST /2/tweets` with OAuth 1.0a user context. The HMAC-SHA1 signer is stdlib and tested against X's documented example. Before the first post of a run it calls `GET /2/users/me` and aborts the run unless the token belongs to `account` / `account_id`. |
|
|
176
|
+
| `outbox` | Never posts. It appends to `outbox/outbox.jsonl` and also writes `outbox.json` (JSON Feed) and `outbox.txt`, for human review or another tool. |
|
|
177
|
+
| `dry-run` | Never posts and records nothing. It prints each post with its weighted length. |
|
|
178
|
+
| `package.module:Class` | Your own backend: subclass `repocast.backends.Backend`, then implement `post_thread(posts, item) -> ids` and optionally `verify()`. |
|
|
179
|
+
|
|
180
|
+
## Safety model
|
|
181
|
+
|
|
182
|
+
* **Off by default.** A backend that publishes (`x`) runs as a dry run unless `REPOCAST_LIVE=true` and all four
|
|
183
|
+
`X_*` credentials are set. The variable name can be changed with `live_env`.
|
|
184
|
+
* **Kill switch.** When `REPOCAST_KILL_SWITCH` is true, the run does nothing at all. The variable name can be changed
|
|
185
|
+
with `kill_switch_env`. In Actions, make it a repository *variable* so you can flip it from your phone.
|
|
186
|
+
* **No backfill.** The first run, and the first live run after dry runs, mark every existing item as handled.
|
|
187
|
+
Posting starts with the next change.
|
|
188
|
+
* **Dedup.** The state file records every item that was posted or skipped, plus a hash of the text. The same
|
|
189
|
+
change never posts twice, and neither does the same text. It is written atomically.
|
|
190
|
+
* **Caps.** `max_per_run` and `max_per_day` apply, with the day counted in `timezone`. Items over the cap are
|
|
191
|
+
deferred, not dropped. Items older than `stale_after_hours` are skipped.
|
|
192
|
+
* **Quiet hours.** `quiet_hours = "22:00-07:00"` in `timezone` defers the whole run.
|
|
193
|
+
* **Never retry an unknown outcome.** A timeout, a reset connection or a 5xx response might mean the post exists,
|
|
194
|
+
so the item is marked `uncertain` and is never retried automatically. A 4xx response means a definite failure,
|
|
195
|
+
which is retried up to `max_attempts`. If a thread fails partway, it is recorded as `partial` with the IDs that
|
|
196
|
+
were posted. After you check the account, `repocast forget <sha> --force` makes repocast consider an item again.
|
|
197
|
+
* **Right account only.** If `GET /2/users/me` fails or names another account, the run aborts before any post.
|
|
198
|
+
Check it yourself with `repocast verify-account --account MyBot`.
|
|
199
|
+
* **Exit codes.** `0` means OK, `1` means something failed or is uncertain (so CI turns red), `2` means bad
|
|
200
|
+
config/input, and `3` means the account check failed.
|
|
201
|
+
|
|
202
|
+
## Running an automated account on X
|
|
203
|
+
|
|
204
|
+
X has specific rules for bots, and they change, so read the current versions:
|
|
205
|
+
[Developer Guidelines → Automation rules](https://docs.x.com/developer-guidelines),
|
|
206
|
+
[X automation rules (Help Center)](https://help.x.com/en/rules-and-policies/x-automation) and the
|
|
207
|
+
[Developer Policy](https://developer.x.com/en/developer-terms/policy).
|
|
208
|
+
|
|
209
|
+
**The "Automated by" label.** Log in as the bot, open *Settings and privacy → Your account → Account information →
|
|
210
|
+
Automation* ([x.com/settings/account/automation](https://x.com/settings/account/automation)), and set the managing
|
|
211
|
+
account to the human account responsible for the bot. The profile then shows "Automated by @you".
|
|
212
|
+
|
|
213
|
+
**Cost: Pay Per Use credits.** The X API is billed per request from prepaid credits, and creating a post is a
|
|
214
|
+
billable write. **Prices change, and posts that contain links can be priced differently,** so this README quotes no
|
|
215
|
+
numbers. Check [X API pricing](https://docs.x.com/x-api/getting-started/pricing) and the Developer Console before
|
|
216
|
+
going live, and set a spending limit there. repocast's caps (`max_per_run`, `max_per_day`, `max_posts`) are also
|
|
217
|
+
your cost ceiling: a thread of N posts makes N billable create requests. The `GET /2/users/me` check is a read and
|
|
218
|
+
is made only on runs that are about to post. An `outbox` or `dry-run` backend makes no API calls at all.
|
|
219
|
+
|
|
220
|
+
**Automated-account checklist**
|
|
221
|
+
|
|
222
|
+
- [ ] The bot has its own account. Never use your personal account's tokens.
|
|
223
|
+
- [ ] The "Automated by @you" label is on, and the bio says it is a bot and who runs it.
|
|
224
|
+
- [ ] The X app has *Read and write* permission. The access token was generated **after** setting that permission
|
|
225
|
+
(an older token stays read-only).
|
|
226
|
+
- [ ] `account` (or `account_id`) in `repocast.toml` is the bot's handle. Run `repocast verify-account` once.
|
|
227
|
+
- [ ] Credentials are stored only as CI secrets. `REPOCAST_LIVE` and `REPOCAST_KILL_SWITCH` are repository
|
|
228
|
+
variables.
|
|
229
|
+
- [ ] The posts are original, informative and not repetitive. There are no unsolicited @mentions or replies and no
|
|
230
|
+
hashtag stuffing (the gate blocks mentions and hashtags by default).
|
|
231
|
+
- [ ] Caps are sized to your budget, a spending limit is set in the Developer Console, and quiet hours are set if
|
|
232
|
+
your audience sleeps.
|
|
233
|
+
- [ ] Watched `repocast preview` and a few `dry-run`/`outbox` runs before flipping `REPOCAST_LIVE`.
|
|
234
|
+
- [ ] Someone watches the bot's mentions and failed runs (exit code 1 turns CI red), and knows where the kill switch
|
|
235
|
+
is.
|
|
236
|
+
|
|
237
|
+
## Examples
|
|
238
|
+
|
|
239
|
+
* [`examples/x-algorithm/`](examples/x-algorithm/): knoblog's x-algorithm feed in `prebuilt` mode. It reproduces the
|
|
240
|
+
@XAlgoChangelog post text exactly (`expected-posts.json`, checked by the tests).
|
|
241
|
+
* [`examples/px4/`](examples/px4/): knoblog → X for PX4 firmware defaults, sent to an outbox with quiet hours.
|
|
242
|
+
* [`examples/nav2-template/`](examples/nav2-template/): Nav2 parameter changes in your own words, using a custom
|
|
243
|
+
template with emoji.
|
|
244
|
+
|
|
245
|
+
```text
|
|
246
|
+
🤖 Nav2 defaults changed (Aug 15) ⚙️
|
|
247
|
+
▸ velocity_smoother/max_accel[0]: 2.5 → 3.0 (20% higher)
|
|
248
|
+
▸ velocity_smoother/max_decel[0]: -2.5 → -3.0 (20% lower)
|
|
249
|
+
▸ velocity_smoother/max_accel[2]: 3.2 → 3.5 (9.4% higher)
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
## Development
|
|
253
|
+
|
|
254
|
+
```bash
|
|
255
|
+
python -m unittest discover -s tests -v # no network: the X API is mocked
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
MIT licensed. © 2026 Joshua Almeida.
|
repocast-0.1.0/README.md
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
# repocast
|
|
2
|
+
|
|
3
|
+
**Turn any repo's changes into accurate, ready-to-post X posts, and post them safely.**
|
|
4
|
+
|
|
5
|
+
repocast is for people who build bots that post on X about code changes, such as changelog bots, "parameter
|
|
6
|
+
watch" accounts, or release announcers. You give it structured change data. It writes posts or threads that fit X's
|
|
7
|
+
weighted 280-character limit and checks that every number in them comes from the data. It then posts them through
|
|
8
|
+
the X API v2, or writes them to an outbox, or only prints them. The guard rails a bot needs are built in: dedup,
|
|
9
|
+
caps, a kill switch, quiet hours, an account check, and no retries when the outcome is unknown.
|
|
10
|
+
|
|
11
|
+
Pure Python standard library (3.10+); no third-party dependency at runtime.
|
|
12
|
+
|
|
13
|
+
```
|
|
14
|
+
knoblog feed.json / changes.json ─┐
|
|
15
|
+
repocast diff (built-in, simple) ─┼─▶ compose ─▶ accuracy gate ─▶ dedup/caps/switches ─▶ X API v2 │ outbox │ dry run
|
|
16
|
+
your own items JSON ─┘ (prebuilt text or {placeholder} templates)
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
It grew out of the [@XAlgoChangelog](https://github.com/fitzyracing1/x-algorithm-changelog) bot. With the config in
|
|
20
|
+
`examples/x-algorithm/` it reproduces that bot's post text exactly, for all 23 post-worthy commits (this is a test).
|
|
21
|
+
|
|
22
|
+
## Quickstart (CLI)
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
pip install repocast # or: pip install "repocast[knoblog]"
|
|
26
|
+
repocast count "🤖 Nav2 defaults changed → https://example.com" # 51/280
|
|
27
|
+
|
|
28
|
+
# 1) change data: knoblog's output (preferred) or the built-in diff
|
|
29
|
+
knoblog run --config knoblog.yml --out site # writes site/feed.json, changes.json, posts.json
|
|
30
|
+
# or
|
|
31
|
+
repocast diff --repo . --range v1.4.0..HEAD -o items.json
|
|
32
|
+
|
|
33
|
+
# 2) look at what would be posted (no state, no caps, posts nothing)
|
|
34
|
+
repocast preview -i site/feed.json
|
|
35
|
+
|
|
36
|
+
# 3) a real run. Without REPOCAST_LIVE=true this is still a dry run and records nothing.
|
|
37
|
+
repocast run -c repocast.toml
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
`repocast.toml`:
|
|
41
|
+
|
|
42
|
+
```toml
|
|
43
|
+
[input]
|
|
44
|
+
path = "site/feed.json" # knoblog feed.json / changes.json (+ posts.json), or repocast items JSON
|
|
45
|
+
|
|
46
|
+
[format]
|
|
47
|
+
mode = "auto" # prebuilt (knoblog's text) | template | auto (prebuilt when available)
|
|
48
|
+
project = "MyRobot"
|
|
49
|
+
disclaimer = "Values are repo defaults."
|
|
50
|
+
max_lines = 3 # change lines after the headline
|
|
51
|
+
max_posts = 4 # longest thread allowed
|
|
52
|
+
|
|
53
|
+
[format.templates] # optional; plain {placeholders}, no template engine
|
|
54
|
+
header = "🤖 {project} update ({date}): {lead}"
|
|
55
|
+
line = "{name} {old} → {new} ({change})"
|
|
56
|
+
|
|
57
|
+
[filter]
|
|
58
|
+
exclude_files = "(^|/)tests?/"
|
|
59
|
+
|
|
60
|
+
[post]
|
|
61
|
+
backend = "x" # x | outbox | dry-run | package.module:Class
|
|
62
|
+
account = "MyRobotChanges" # the ONLY account this bot may post as (checked via GET /2/users/me)
|
|
63
|
+
state = ".repocast/state.json" # dedup state: commit it or cache it between runs
|
|
64
|
+
max_per_run = 1
|
|
65
|
+
max_per_day = 3
|
|
66
|
+
stale_after_hours = 72
|
|
67
|
+
quiet_hours = "22:00-07:00"
|
|
68
|
+
timezone = "America/New_York"
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Quickstart (GitHub Action)
|
|
72
|
+
|
|
73
|
+
```yaml
|
|
74
|
+
- uses: fitzyracing1/knoblog@v0 # or any step that writes change data
|
|
75
|
+
with: { config: knoblog.yml, out: site }
|
|
76
|
+
- id: rc
|
|
77
|
+
uses: fitzyracing1/repocast@v0
|
|
78
|
+
with: { config: repocast.toml }
|
|
79
|
+
env:
|
|
80
|
+
REPOCAST_LIVE: ${{ vars.REPOCAST_LIVE }} # 'true' to actually post
|
|
81
|
+
REPOCAST_KILL_SWITCH: ${{ vars.REPOCAST_KILL_SWITCH }} # 'true' stops everything
|
|
82
|
+
X_API_KEY: ${{ secrets.X_API_KEY }}
|
|
83
|
+
X_API_SECRET: ${{ secrets.X_API_SECRET }}
|
|
84
|
+
X_ACCESS_TOKEN: ${{ secrets.X_ACCESS_TOKEN }}
|
|
85
|
+
X_ACCESS_TOKEN_SECRET: ${{ secrets.X_ACCESS_TOKEN_SECRET }}
|
|
86
|
+
# then commit .repocast/state.json when steps.rc.outputs.state_changed == 'true'
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
A complete scheduled workflow is in [`examples/workflows/post-changes.yml`](examples/workflows/post-changes.yml). It
|
|
90
|
+
covers concurrency, the state commit, and the switches. Action outputs: `posted`, `would_post`, `state_changed`.
|
|
91
|
+
Each run also writes a step summary showing every post.
|
|
92
|
+
|
|
93
|
+
## Input
|
|
94
|
+
|
|
95
|
+
| Source | What repocast reads |
|
|
96
|
+
| --- | --- |
|
|
97
|
+
| knoblog `feed.json` (preferred) | per commit: the change records **and** knoblog's ready-made post text / skip reason |
|
|
98
|
+
| knoblog `changes.json` (+ `posts.json` next to it) | flat change records grouped by commit; prebuilt posts if `posts.json` is there |
|
|
99
|
+
| `repocast diff` | `KEY = VALUE`, `KEY: VALUE`, `#define KEY VALUE` and `const KEY: T = VALUE;` changes per commit. A key that appears twice in one file is dropped as ambiguous. Use knoblog for nested YAML, parameter tables, macros and expressions. |
|
|
100
|
+
| your own JSON | `{"items": [{"id", "date", "commit_url", "url", "changes": [{"name", "old", "new", "kind", "file"}], "posts"?}]}` |
|
|
101
|
+
|
|
102
|
+
`kind` is `changed`, `added`, `removed`, `moved` or `renamed`. knoblog is an optional extra
|
|
103
|
+
(`pip install "repocast[knoblog]"`). repocast only reads knoblog's files, so either tool can run in a separate step.
|
|
104
|
+
|
|
105
|
+
## Formatting
|
|
106
|
+
|
|
107
|
+
* **X weighted counting** (twitter-text v3 rules). Every URL counts as 23. Domain-like tokens such as `params.rs` also
|
|
108
|
+
count as 23, and the gate rejects them because X would auto-link them. CJK characters and most symbols (`→`, `▸`)
|
|
109
|
+
weigh 2. An emoji weighs 2 whatever its length: ZWJ families, skin tones, flags and keycaps included. Text is NFC
|
|
110
|
+
normalised. Run `repocast count "…"` to check a text.
|
|
111
|
+
* **Threads.** The headline goes in the first post, then whole change lines, then the footer on the last post. The
|
|
112
|
+
footer is never left alone in a tiny reply. Each reply is chained to the post before it with
|
|
113
|
+
`reply.in_reply_to_tweet_id`.
|
|
114
|
+
* **Templates** use plain `{placeholders}`, written `{{`/`}}` for literal braces. An unknown placeholder fails when
|
|
115
|
+
the config loads, not at 3 a.m. A line whose placeholders are all empty is dropped, so `{url}` disappears when
|
|
116
|
+
there is no details page.
|
|
117
|
+
|
|
118
|
+
| template | placeholders |
|
|
119
|
+
| --- | --- |
|
|
120
|
+
| `header`, `footer`, `more_line` | `{project} {date} {count} {short} {id} {title} {commit_url} {url} {disclaimer} {more} {lead}` |
|
|
121
|
+
| `line`, `text_line`, `flag_line`, `added_line`, `removed_line`, `other_line` | `{name} {old} {new} {change} {pct} {ratio} {file} {kind} {type}` + `{project} {date} {short} {commit_url} {url}` |
|
|
122
|
+
| `bullet` | (literal prefix for each change line, default `- `) |
|
|
123
|
+
|
|
124
|
+
`{change}` reads like `25% higher`, `7.2x lower` or `sign flipped`. Highlighted names (`highlight = [...]`) lead,
|
|
125
|
+
then the biggest relative changes. `name_style = "safe"` (the default) turns dotted names that X would link into
|
|
126
|
+
slash form. `display_names` maps raw names to friendly ones.
|
|
127
|
+
|
|
128
|
+
## The accuracy gate
|
|
129
|
+
|
|
130
|
+
A post is sent only if **every number in it traces back to the change data**. A number passes if it is one of these:
|
|
131
|
+
|
|
132
|
+
* an old or new value of one of the item's changes (`125,000` matches `125_000`);
|
|
133
|
+
* a correctly rounded quantity derived from one change: percent change, ratio, inverse ratio or difference. Unit
|
|
134
|
+
conversions (ms ↔ s ↔ min ↔ h ↔ d, KiB/MiB) count only when the parameter name suggests a unit, as in `timeout_ms`;
|
|
135
|
+
* a count no larger than the number of changes (`+3 more`), or part of the item's date;
|
|
136
|
+
* a number you typed into your own templates. With prebuilt text, numbers in `trusted_text` and the disclaimer
|
|
137
|
+
also pass.
|
|
138
|
+
|
|
139
|
+
The gate also rejects @mentions and #hashtags (unless allowed), domain-like tokens outside real links, any post
|
|
140
|
+
over 280 weighted characters, and threads longer than `max_posts`. A failed check never goes to X. The item is
|
|
141
|
+
skipped and the reason is recorded:
|
|
142
|
+
|
|
143
|
+
```
|
|
144
|
+
=== 1a2b3c4 SKIP: gate: unverified number '50' in post 1: not in the change data
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
Prebuilt text, such as knoblog's, goes through the same gate, so a bug or a hand edit upstream cannot put a wrong
|
|
148
|
+
number on X.
|
|
149
|
+
|
|
150
|
+
## Posting backends
|
|
151
|
+
|
|
152
|
+
| backend | what it does |
|
|
153
|
+
| --- | --- |
|
|
154
|
+
| `x` | X API v2 `POST /2/tweets` with OAuth 1.0a user context. The HMAC-SHA1 signer is stdlib and tested against X's documented example. Before the first post of a run it calls `GET /2/users/me` and aborts the run unless the token belongs to `account` / `account_id`. |
|
|
155
|
+
| `outbox` | Never posts. It appends to `outbox/outbox.jsonl` and also writes `outbox.json` (JSON Feed) and `outbox.txt`, for human review or another tool. |
|
|
156
|
+
| `dry-run` | Never posts and records nothing. It prints each post with its weighted length. |
|
|
157
|
+
| `package.module:Class` | Your own backend: subclass `repocast.backends.Backend`, then implement `post_thread(posts, item) -> ids` and optionally `verify()`. |
|
|
158
|
+
|
|
159
|
+
## Safety model
|
|
160
|
+
|
|
161
|
+
* **Off by default.** A backend that publishes (`x`) runs as a dry run unless `REPOCAST_LIVE=true` and all four
|
|
162
|
+
`X_*` credentials are set. The variable name can be changed with `live_env`.
|
|
163
|
+
* **Kill switch.** When `REPOCAST_KILL_SWITCH` is true, the run does nothing at all. The variable name can be changed
|
|
164
|
+
with `kill_switch_env`. In Actions, make it a repository *variable* so you can flip it from your phone.
|
|
165
|
+
* **No backfill.** The first run, and the first live run after dry runs, mark every existing item as handled.
|
|
166
|
+
Posting starts with the next change.
|
|
167
|
+
* **Dedup.** The state file records every item that was posted or skipped, plus a hash of the text. The same
|
|
168
|
+
change never posts twice, and neither does the same text. It is written atomically.
|
|
169
|
+
* **Caps.** `max_per_run` and `max_per_day` apply, with the day counted in `timezone`. Items over the cap are
|
|
170
|
+
deferred, not dropped. Items older than `stale_after_hours` are skipped.
|
|
171
|
+
* **Quiet hours.** `quiet_hours = "22:00-07:00"` in `timezone` defers the whole run.
|
|
172
|
+
* **Never retry an unknown outcome.** A timeout, a reset connection or a 5xx response might mean the post exists,
|
|
173
|
+
so the item is marked `uncertain` and is never retried automatically. A 4xx response means a definite failure,
|
|
174
|
+
which is retried up to `max_attempts`. If a thread fails partway, it is recorded as `partial` with the IDs that
|
|
175
|
+
were posted. After you check the account, `repocast forget <sha> --force` makes repocast consider an item again.
|
|
176
|
+
* **Right account only.** If `GET /2/users/me` fails or names another account, the run aborts before any post.
|
|
177
|
+
Check it yourself with `repocast verify-account --account MyBot`.
|
|
178
|
+
* **Exit codes.** `0` means OK, `1` means something failed or is uncertain (so CI turns red), `2` means bad
|
|
179
|
+
config/input, and `3` means the account check failed.
|
|
180
|
+
|
|
181
|
+
## Running an automated account on X
|
|
182
|
+
|
|
183
|
+
X has specific rules for bots, and they change, so read the current versions:
|
|
184
|
+
[Developer Guidelines → Automation rules](https://docs.x.com/developer-guidelines),
|
|
185
|
+
[X automation rules (Help Center)](https://help.x.com/en/rules-and-policies/x-automation) and the
|
|
186
|
+
[Developer Policy](https://developer.x.com/en/developer-terms/policy).
|
|
187
|
+
|
|
188
|
+
**The "Automated by" label.** Log in as the bot, open *Settings and privacy → Your account → Account information →
|
|
189
|
+
Automation* ([x.com/settings/account/automation](https://x.com/settings/account/automation)), and set the managing
|
|
190
|
+
account to the human account responsible for the bot. The profile then shows "Automated by @you".
|
|
191
|
+
|
|
192
|
+
**Cost: Pay Per Use credits.** The X API is billed per request from prepaid credits, and creating a post is a
|
|
193
|
+
billable write. **Prices change, and posts that contain links can be priced differently,** so this README quotes no
|
|
194
|
+
numbers. Check [X API pricing](https://docs.x.com/x-api/getting-started/pricing) and the Developer Console before
|
|
195
|
+
going live, and set a spending limit there. repocast's caps (`max_per_run`, `max_per_day`, `max_posts`) are also
|
|
196
|
+
your cost ceiling: a thread of N posts makes N billable create requests. The `GET /2/users/me` check is a read and
|
|
197
|
+
is made only on runs that are about to post. An `outbox` or `dry-run` backend makes no API calls at all.
|
|
198
|
+
|
|
199
|
+
**Automated-account checklist**
|
|
200
|
+
|
|
201
|
+
- [ ] The bot has its own account. Never use your personal account's tokens.
|
|
202
|
+
- [ ] The "Automated by @you" label is on, and the bio says it is a bot and who runs it.
|
|
203
|
+
- [ ] The X app has *Read and write* permission. The access token was generated **after** setting that permission
|
|
204
|
+
(an older token stays read-only).
|
|
205
|
+
- [ ] `account` (or `account_id`) in `repocast.toml` is the bot's handle. Run `repocast verify-account` once.
|
|
206
|
+
- [ ] Credentials are stored only as CI secrets. `REPOCAST_LIVE` and `REPOCAST_KILL_SWITCH` are repository
|
|
207
|
+
variables.
|
|
208
|
+
- [ ] The posts are original, informative and not repetitive. There are no unsolicited @mentions or replies and no
|
|
209
|
+
hashtag stuffing (the gate blocks mentions and hashtags by default).
|
|
210
|
+
- [ ] Caps are sized to your budget, a spending limit is set in the Developer Console, and quiet hours are set if
|
|
211
|
+
your audience sleeps.
|
|
212
|
+
- [ ] Watched `repocast preview` and a few `dry-run`/`outbox` runs before flipping `REPOCAST_LIVE`.
|
|
213
|
+
- [ ] Someone watches the bot's mentions and failed runs (exit code 1 turns CI red), and knows where the kill switch
|
|
214
|
+
is.
|
|
215
|
+
|
|
216
|
+
## Examples
|
|
217
|
+
|
|
218
|
+
* [`examples/x-algorithm/`](examples/x-algorithm/): knoblog's x-algorithm feed in `prebuilt` mode. It reproduces the
|
|
219
|
+
@XAlgoChangelog post text exactly (`expected-posts.json`, checked by the tests).
|
|
220
|
+
* [`examples/px4/`](examples/px4/): knoblog → X for PX4 firmware defaults, sent to an outbox with quiet hours.
|
|
221
|
+
* [`examples/nav2-template/`](examples/nav2-template/): Nav2 parameter changes in your own words, using a custom
|
|
222
|
+
template with emoji.
|
|
223
|
+
|
|
224
|
+
```text
|
|
225
|
+
🤖 Nav2 defaults changed (Aug 15) ⚙️
|
|
226
|
+
▸ velocity_smoother/max_accel[0]: 2.5 → 3.0 (20% higher)
|
|
227
|
+
▸ velocity_smoother/max_decel[0]: -2.5 → -3.0 (20% lower)
|
|
228
|
+
▸ velocity_smoother/max_accel[2]: 3.2 → 3.5 (9.4% higher)
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
## Development
|
|
232
|
+
|
|
233
|
+
```bash
|
|
234
|
+
python -m unittest discover -s tests -v # no network: the X API is mocked
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
MIT licensed. © 2026 Joshua Almeida.
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "repocast"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "Turn any repo's changes into accurate, ready-to-post X posts, and post them safely (OAuth 1.0a, threads, dedup, caps, kill switch)."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
authors = [{ name = "Joshua Almeida" }]
|
|
13
|
+
requires-python = ">=3.10"
|
|
14
|
+
dependencies = ["tomli>=2; python_version < '3.11'"]
|
|
15
|
+
keywords = ["x", "twitter", "bot", "changelog", "github-action", "release-notes", "automation"]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Operating System :: OS Independent",
|
|
19
|
+
"Topic :: Software Development :: Build Tools",
|
|
20
|
+
"Topic :: Communications",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
[project.optional-dependencies]
|
|
24
|
+
knoblog = ["knoblog>=0.1"]
|
|
25
|
+
|
|
26
|
+
[project.urls]
|
|
27
|
+
Homepage = "https://github.com/fitzyracing1/repocast"
|
|
28
|
+
Issues = "https://github.com/fitzyracing1/repocast/issues"
|
|
29
|
+
|
|
30
|
+
[project.scripts]
|
|
31
|
+
repocast = "repocast.cli:main"
|
|
32
|
+
|
|
33
|
+
[tool.setuptools]
|
|
34
|
+
packages = ["repocast"]
|
|
35
|
+
|
|
36
|
+
[tool.setuptools.dynamic]
|
|
37
|
+
version = { attr = "repocast.__version__" }
|