diff --git a/.procoder/adr/0011-reddit-is-live-only-and-never-stored.md b/.procoder/adr/0011-reddit-is-live-only-and-never-stored.md new file mode 100644 index 0000000..c6e5078 --- /dev/null +++ b/.procoder/adr/0011-reddit-is-live-only-and-never-stored.md @@ -0,0 +1,53 @@ +# 0011 — Reddit is read live through OAuth and never stored + +Status: accepted +Date: 2026-09-04 + +## Context + +Reddit's Data API Wiki and Responsible Builder Policy set three rules that the +first implementation broke. + +The agent string must be `:: (by /u/)`, and +Reddit states it throttles or blocks unidentified clients. Wheel sent one shared +hardcoded agent for every installation. + +Traffic that does not use OAuth is blocked, and masking how Reddit data is +reached is prohibited. Wheel fell back to a managed page reader whenever OAuth +was unconfigured or failed, which is exactly that mask. + +Content deleted on Reddit must be removed from every copy held, including +titles, bodies and embedded URLs; retention is a violation even when anonymized. +Wheel wrote Reddit titles and permalinks into decision files that live in git +forever, where a deletion can never be honoured. + +## Decision + +OAuth is the only route. The page-reader fallback for Reddit is deleted; an +unconfigured install reports `blocked` with the reason and simply has no Reddit +evidence. + +The agent string is built from `WHEEL_REDDIT_USERNAME` in the documented format, +or replaced wholesale by `WHEEL_REDDIT_USER_AGENT`. An install that names nobody +raises before a request is made rather than sending an anonymous one. + +The published budget of 100 queries per minute per client id is honoured from +the `x-ratelimit-remaining` and `x-ratelimit-reset` headers, refusing the next +call inside a five-request reserve. + +Reddit content is not persisted. `_reject_reddit_content` runs inside the same +validators that already reject secrets, so any decision or run record carrying a +`reddit.com` or `redd.it` link fails to write. Findings are paraphrased and the +source is cited as one to re-query. + +## Consequences + +Reddit evidence now costs a registered app and a declared username, and users +who configure neither lose the source entirely instead of getting it through a +side door. Reddit findings cannot be audited from a stored link later; the run +that used them is the only place they existed, and a re-check means a re-query. + +In exchange every rule that has an enforcement mechanism is enforced by code +rather than by a sentence in the README: the wrong agent cannot be sent, the +budget cannot be overrun silently, and the retention rule cannot be broken by an +agent that decides a permalink would look good in an ADR. diff --git a/.procoder/adr/0012-reddit-archive-is-the-second-lane.md b/.procoder/adr/0012-reddit-archive-is-the-second-lane.md new file mode 100644 index 0000000..a750da6 --- /dev/null +++ b/.procoder/adr/0012-reddit-archive-is-the-second-lane.md @@ -0,0 +1,59 @@ +# 0012 — A public archive is Reddit's second lane, not a disguise for the first + +Status: accepted +Date: 2026-09-04 + +## Context + +ADR 0011 made OAuth the only way into Reddit and deleted the page-reader +fallback, which was correct on policy and left a hole in practice: an install +without a registered Reddit app has no community evidence from the largest +engineering forum on the internet. + +The `last30days` skill solves the same problem with four keyless lanes: RSS +feeds, shreddit `/svc` listing partials, shreddit comment partials, and the +Arctic Shift archive. The first three read reddit.com's own surfaces without +authentication, which is exactly the masking Reddit's Responsible Builder Policy +prohibits, and they were measured to answer 403 from ordinary contexts anyway. +The fourth is different in kind: Arctic Shift is an independent public archive +on its own infrastructure. + +Measured against the live service before choosing: `title` search requires a +`subreddit` or `author` alongside it, `after` filters by date, a listing query +returns 100 rows in under a second, a title query takes about five, and bursts +answer HTTP 422 "Timeout. Maybe slow down a bit". + +## Decision + +The `reddit-archive` lane searches Arctic Shift and runs only when the Reddit +API lane did not return `ok`. It reports under its own source name so a reader +can never mistake an archive snapshot for a live Reddit answer. + +No Reddit credential is sent to it, no Reddit rate budget is consumed, and no +Reddit account can be sanctioned for it: it is a third party, and the request +carries wheel's own agent string. + +Because the archive requires a subreddit, `registry/reddit_subreddits.yaml` maps +capabilities to at most four subreddits each, with an engineering default. The +file is optional and pyyaml is optional, exactly as with probe terms. + +Requests are paced 2.5 seconds apart with one retry on 422/429, and the archived +`score` is surfaced as `score_at_archive` and excluded from ranking, because it +is the count at ingest time rather than now. + +The retention rule from ADR 0011 is unchanged and applies here too: these rows +are Reddit user content, so they carry `retention: none` and the decision +validators still refuse to write a reddit link into a recorded file. + +## Consequences + +An unconfigured install gets Reddit evidence again, at the cost of freshness and +of engagement numbers that cannot be trusted. Wheel now depends on a volunteer +archive that can disappear or rate-limit; the lane degrades to `partial` or +`unavailable` and says which subreddit failed, so its absence is visible rather +than silent. + +Rejected: the shreddit `/svc` partials and the RSS feeds. They read Reddit's own +surfaces without identifying the client, which is the practice ADR 0011 removed +on purpose. Reintroducing them through a different module would be the same +violation with a new name. diff --git a/.procoder/adr/0013-rejected-reddit-routes.md b/.procoder/adr/0013-rejected-reddit-routes.md new file mode 100644 index 0000000..689d1d4 --- /dev/null +++ b/.procoder/adr/0013-rejected-reddit-routes.md @@ -0,0 +1,65 @@ +# 0013 — The Reddit routes that were tested and rejected + +Status: accepted +Date: 2026-09-04 + +## Context + +After ADR 0012 added the Arctic Shift lane, the obvious question was whether a +better free route exists. Every candidate below was called from this machine +before it was judged, so the record is measurements rather than reputation. + +`https://old.reddit.com/search.json?q=...` answers HTTP 200 with 352 KB of HTML. +The same for `old.reddit.com/r//.json`. The JSON endpoints are gone in +practice: what arrives is a rendered page, so a parser would be scraping the web +UI while pretending to consume an API. + +`https://www.reddit.com/search.rss` answers HTTP 200 with a real Atom feed, four +entries, in under a second. It works. It is also reddit.com served to a client +that never identified itself through OAuth, which is the practice ADR 0011 +removed on purpose. + +`https://old.reddit.com/r//top.rss` answers 200 with HTML containing +`shreddit-post` elements rather than a feed: the old host redirects into the new +UI, so the shreddit-partial technique and the RSS technique collapse into the +same scrape. + +`https://api.pullpush.io/reddit/search/submission/` is the widest option on +paper: keyless full-text search with no subreddit required, five rows in 1.2 +seconds, and a comment endpoint that returns the body text where migration +stories actually live. The second request in the same minute answered HTTP 429 +with `"Rate limit exceeded. This website does not provide free scraping +resources for agents. Please contact the admin"`. The operator's own error +message refuses this use. + +Arctic Shift's `/api/comments/search` answered HTTP 422 "Timeout. Maybe slow +down a bit" on every attempt across two subreddits and two retries. Comment +search is not available at this volume. + +Arctic Shift's `/api/posts/search` accepts `selftext` alongside a subreddit and +returned rows in 7.9 seconds. + +## Decision + +Nothing new is added from reddit.com itself. The `.json` endpoints are HTML, and +the RSS feed, while functional, is the same unidentified read that ADR 0011 +forbids; a working technique is not an allowed one. + +PullPush is not used. A service that answers "does not provide free scraping +resources for agents" has told us what it wants, and routing around that with +pacing would be the same disrespect the Reddit fallback was deleted for. + +The Arctic Shift lane gains a `selftext` sweep: for each subreddit the title is +queried first, and the body only when the title returned nothing, so the cost is +one extra request per empty subreddit rather than a doubling. Results carry +`matched_field` and are deduplicated by permalink. + +Comment search stays out until the archive can serve it. + +## Consequences + +The free surface stops growing here. Reddit evidence is a registered app first +and an archive of post titles and bodies second, and neither reaches the comment +threads where the most useful detail sits. That gap is now documented rather +than quietly worked around, and the next person who proposes RSS or PullPush can +read what happened when they were tried. diff --git a/.procoder/adr/0014-no-logged-in-reddit-automation.md b/.procoder/adr/0014-no-logged-in-reddit-automation.md new file mode 100644 index 0000000..6ca0861 --- /dev/null +++ b/.procoder/adr/0014-no-logged-in-reddit-automation.md @@ -0,0 +1,67 @@ +# 0014 — A logged-in browser session is not a Reddit route + +Status: accepted +Date: 2026-09-04 + +## Context + +`opencli` 1.8.7 is installed on this machine and ships a Reddit site adapter with +`login`, `search`, `subreddit`, `read`, `user-comments`, `saved`, and the write +verbs `upvote`, `comment`, `reply`, `subscribe`. It drives a real Chrome profile, +so `read ` returns a post together with its comment tree: exactly the +gap ADR 0013 documented as unreachable, since the archive's comment search times +out and Reddit's own comment endpoints are OAuth-only. + +`agent-reach` 1.5.0 is installed as well and is already the adapter for the +`web`, `documentation`, `github`, `gittrend`, `trendshift` and `hackernews` +routes, with `browser_capability: opencli` and +`browser_session: existing-authenticated` on the two page-reading classes. + +The temptation is obvious: the tooling is present, it works, and it closes the +one hole in the evidence chain. + +HKUDS/CLI-Anything (49k stars, Apache-2.0) was checked in the same pass. It is a +hub of agent-native CLIs for local software — Blender, GIMP, Obsidian, OBS, +Zotero — and carries no Reddit surface. It is irrelevant to this decision. + +## Decision + +Wheel does not read Reddit through a logged-in browser session, and no skill +instruction may tell an agent to. + +Three lines of Reddit's Responsible Builder Policy decide it. "You must not +misrepresent or mask how or why you are accessing Reddit data": an automated +Chrome session presenting itself as a human is that mask. "Apps must register +and create a developer profile to get an App profile label. Apps must not +circumvent any labeling performed by Reddit": a browser session carries no app +label. "App accounts should solely be used to perform app functions (no mixed +use accounts)": the session belongs to a person who also reads Reddit as a +person. + +The enforcement paragraph names the price: suspending the app, the account, and +associated accounts. The account at risk is the user's own, and the entire +reason app-only OAuth was chosen in ADR 0011 was that no human account should be +exposed by a research tool. + +The write verbs are refused independently and permanently: the policy's +prohibited-activities section names vote and karma manipulation, and a research +gate has no business casting a vote. + +This is recorded in `registry/sources.yaml` next to the reddit route and in +`skills/wheel-research/SKILL.md`, so an agent that finds `opencli reddit` on the +machine reads the prohibition before it reasons its way into using it. + +## Consequences + +Comment threads stay out of reach. Wheel sees Reddit titles and post bodies and +never the thread where someone explains what broke in production, which is a +real loss of evidence quality and is stated as such rather than papered over. + +`opencli` and `agent-reach` remain fully in use for everything they are allowed +to do: GitHub, GitTrend, Trendshift, Hacker News, documentation pages, and any +page whose owner expects an authenticated read by its own user. + +If Reddit ever offers an approved path for this kind of tool, or the user +accepts the account risk deliberately for a one-off manual check, that is a +human decision executed by hand outside the gate, not a lane wired into the +automated evidence chain. diff --git a/CHANGELOG.md b/CHANGELOG.md index 571f485..33bb2a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,74 @@ where something could not be verified, this file says so. ### Added +- **`reddit-archive`, a keyless second lane.** When Reddit's API does not answer, + `community-signals` searches [Arctic Shift](https://arctic-shift.photon-reddit.com), + an independent public archive, under its own source name. No Reddit credential, + no Reddit budget, no account to sanction. `registry/reddit_subreddits.yaml` + maps capabilities to subreddits because the archive requires one alongside a + title query; `--subreddits` and `--capability` override it. Requests are paced + 2.5s apart with one retry on the archive's 422 "slow down". Archived `score` is + reported as `score_at_archive` and excluded from ranking: it is the count at + ingest, not now. Verified live: 5 real threads for Kafka in r/dataengineering, + including one titled "Kafka deleted our data and every dashboard said we were + healthy". The shreddit `/svc` partials and RSS lanes used by `last30days` were + rejected: they read reddit.com without identifying the client, which is the + practice removed in this same release. +- **The archive lane also sweeps post bodies.** Each subreddit is queried by + title first and by `selftext` only when the title returned nothing, so a + migration story written in the body is found for one extra request rather than + a doubling. Matches carry `matched_field` and are deduplicated by permalink. + Every other free route was called before being judged and is recorded in ADR + 0013: `old.reddit.com/search.json` now answers 352 KB of HTML, `search.rss` + works but is an unidentified read of reddit.com, PullPush answers + `"This website does not provide free scraping resources for agents"`, and + Arctic Shift's comment search times out at any volume. + +### Changed + +- **Reddit is now read only through OAuth, and never stored.** Three rules from + Reddit's Data API Wiki and Responsible Builder Policy were being broken: + - The agent string must be `:: (by /u/)` + and unidentified clients are throttled or blocked. It is now built from + `WHEEL_REDDIT_USERNAME`, or replaced by `WHEEL_REDDIT_USER_AGENT`. An install + that names nobody raises before a request leaves the machine. + - Masking how Reddit data is reached is prohibited, so the managed page-reader + fallback is deleted. Without credentials the source reports `blocked` and the + run has no Reddit evidence, which is the honest outcome. + - Deleted posts must be purged from every copy held, which a decision file in + git cannot do. `_reject_reddit_content` runs inside the same validators that + reject secrets: any decision or run record carrying a `reddit.com` or + `redd.it` link now fails to write. Findings are paraphrased, the source is + cited as one to re-query. +- **The 100 QPM budget is honoured, not discovered.** `x-ratelimit-remaining` and + `x-ratelimit-reset` are read from every Reddit response and the next call is + refused inside a five-request reserve until the window resets. + +### Fixed + +- **Reddit OAuth picked the wrong grant for most apps.** The token request hard + coded `installed_client`, which only a public installed app accepts; a script + or web app carries a secret, is a confidential client and answers to + `client_credentials`. Reddit rejects the mismatch with a bare 401, so the grant + is now selected from whether `WHEEL_REDDIT_CLIENT_SECRET` is set. Verified + against live Reddit: a deliberate wrong-credential probe returns the 401 the + new `--check-reddit` command reports, not a silent empty result. +- **`--check-reddit`.** `scripts/community_signals.py --check-reddit` mints a + token and prints `ok`, `unconfigured` or `error` with the grant it attempted, + exiting non-zero on anything but `ok`. Credentials are never printed, and a + test asserts the failure text carries neither the id nor the secret. +- **`WHEEL_REDDIT_USER_AGENT`.** Reddit throttles by agent string and every + install previously shared one hardcoded value. The default now carries the + version read from the plugin manifest, and an operator can name their own. +- **The helper scripts could not run as scripts.** `community_signals.py` and + `hard_metrics.py` crashed with `AttributeError` under + `python scripts/.py`: the first `getattr` published `_gh_api` into the + module's own globals, and the next `_wheel_core()` call matched the running + module by that attribute and returned itself. The core module is now resolved + once and bound from that object. A test runs all three helpers as files. + +### Added + - **Daily catalog, built by CI.** `.github/workflows/sync-catalog.yml` runs `scripts/build_catalog.py` on a 03:00 UTC cron and publishes `catalog.jsonl` plus `catalog.meta.json` to an orphan `data` branch. No VPS: GitHub Actions is diff --git a/README.md b/README.md index dfb9e3d..57981ce 100644 --- a/README.md +++ b/README.md @@ -170,6 +170,20 @@ independent. - `dependency-debt` returns the transitive count from GitHub's SBOM, the direct count from manifests across seven ecosystems, and the unlicensed package count. On `psf/requests` it measured 6 direct and 30 transitive. When the SBOM is unavailable the status is `partial` and `transitive` is `null`. - `community-signals` probes one regret phrase at a time, because a single query joining every phrase matches nothing on Algolia and is rejected outright by GitHub search. `registry/probe_terms.yaml` carries phrases for en, ru, es, pt, de, fr, zh, and ja; English is always probed, `WHEEL_PROBE_LANGS` adds more, and the total is capped at eight terms per source so the API cost cannot explode. - Reddit uses app-only OAuth through `WHEEL_REDDIT_CLIENT_ID` and `WHEEL_REDDIT_CLIENT_SECRET`. No user account is involved, so the plugin cannot get a user's Reddit account banned. Without credentials it falls back to the managed DonSeTch reader, and then to an honest `blocked` status. +- The grant follows the app type registered at : a **script** or **web app** carries a secret and authenticates with `client_credentials`, an **installed app** carries none and authenticates with `installed_client`. Sending the wrong grant returns a bare 401, so Wheel selects it from whether a secret is set rather than guessing. +- Reddit's [Data API Wiki](https://support.reddithelp.com/hc/en-us/articles/16160319875092-Reddit-Data-API-Wiki) mandates the agent format `:: (by /u/)` and states unidentified clients are throttled or blocked. Set `WHEEL_REDDIT_USERNAME` to your handle and Wheel builds `python:com.kalpakprod.wheel:v (by /u/)`; `WHEEL_REDDIT_USER_AGENT` replaces the whole string. An install that names nobody does not reach Reddit at all: the source reports `blocked` instead of sending an anonymous request. +- The published budget is 100 queries per minute per OAuth client id. Wheel reads `x-ratelimit-remaining` and `x-ratelimit-reset` from every response and refuses the next call once fewer than five requests remain in the window, so the limit is honoured rather than discovered through a 429. +- There is no non-OAuth path. Reading the same content through a page reader would mask how the data was obtained, which the [Responsible Builder Policy](https://support.reddithelp.com/hc/en-us/articles/42728983564564-Responsible-Builder-Policy) forbids, so an unconfigured install simply has no Reddit evidence. +- Nothing from Reddit is written to disk. Reddit requires deleted posts to be purged from every copy held, including titles and embedded URLs, and a decision file in git cannot honour that. `scripts/wheel.py` rejects any decision or run record containing a `reddit.com` or `redd.it` link; Reddit shapes the verdict during the run and is cited as a source to re-query. +- When Reddit itself does not answer, the `reddit-archive` lane searches [Arctic Shift](https://arctic-shift.photon-reddit.com), an independent public archive of Reddit posts. It is not Reddit's API and not a scrape of reddit.com: no credentials are sent, no Reddit account is involved and Reddit's per-client budget is untouched. The archive needs a subreddit alongside a title query, so `registry/reddit_subreddits.yaml` maps each capability to at most four subreddits; `--subreddits` overrides it. Archived `score` is the count at ingest time, so it is reported as `score_at_archive` and never used for ranking. Retention is unchanged: archive rows are Reddit content and still cannot be written into a decision. +- Verify credentials before trusting a run: + + ```bash + python scripts/community_signals.py --check-reddit + ``` + + It prints `ok`, `unconfigured` or `error` with the attempted grant, exits non-zero on anything but `ok`, and never echoes the credentials. + - Read one dynamic page through the managed binary: ```bash diff --git a/README.ru.md b/README.ru.md index 2f6832c..d8d78d9 100644 --- a/README.ru.md +++ b/README.ru.md @@ -169,6 +169,20 @@ - `dependency-debt` возвращает транзитивное число зависимостей из GitHub SBOM, прямое число из манифестов семи экосистем и число пакетов без лицензии. На `psf/requests` измерено 6 прямых и 30 транзитивных. Если SBOM недоступен, статус `partial`, а `transitive` равен `null`. - `community-signals` проверяет по одной фразе за раз, потому что единый запрос со всеми фразами не находит ничего на Algolia и отклоняется GitHub search. `registry/probe_terms.yaml` содержит фразы для en, ru, es, pt, de, fr, zh и ja; английский набор проверяется всегда, `WHEEL_PROBE_LANGS` добавляет остальные, а общее число ограничено восемью терминами на источник, чтобы стоимость API не взорвалась. - Reddit использует app-only OAuth через `WHEEL_REDDIT_CLIENT_ID` и `WHEEL_REDDIT_CLIENT_SECRET`. Пользовательский аккаунт не участвует, поэтому плагин не может привести к бану аккаунта пользователя. Без учётных данных используется управляемый читатель DonSeTch, а затем честный статус `blocked`. +- Тип гранта следует типу приложения, зарегистрированного на : **script** и **web app** имеют секрет и аутентифицируются через `client_credentials`, **installed app** секрета не имеет и работает через `installed_client`. Неверный грант возвращает пустой 401, поэтому Wheel выбирает его по наличию секрета, а не угадывает. +- [Data API Wiki](https://support.reddithelp.com/hc/en-us/articles/16160319875092-Reddit-Data-API-Wiki) требует формат агента `:: (by /u/)` и заявляет, что неопознанные клиенты ограничиваются или блокируются. Задайте `WHEEL_REDDIT_USERNAME`, и Wheel соберёт `python:com.kalpakprod.wheel:v<версия> (by /u/<ник>)`; `WHEEL_REDDIT_USER_AGENT` заменяет строку целиком. Установка, которая себя не называет, до Reddit не доходит: источник отвечает `blocked` вместо анонимного запроса. +- Опубликованный лимит: 100 запросов в минуту на один OAuth client id. Wheel читает `x-ratelimit-remaining` и `x-ratelimit-reset` из каждого ответа и отказывает в следующем вызове, когда в окне остаётся меньше пяти запросов, то есть соблюдает лимит, а не узнаёт о нём из 429. +- Пути в обход OAuth нет. Чтение того же контента через читалку страниц маскировало бы способ доступа к данным, что запрещает [Responsible Builder Policy](https://support.reddithelp.com/hc/en-us/articles/42728983564564-Responsible-Builder-Policy), поэтому без учётных данных доказательств из Reddit просто не будет. +- Ничего из Reddit не пишется на диск. Reddit требует удалять посты из любой хранимой копии, включая заголовки и вложенные ссылки, а файл решения в git этого выполнить не может. `scripts/wheel.py` отклоняет решение или запись прогона со ссылкой `reddit.com` или `redd.it`; Reddit влияет на вердикт во время прогона и указывается как источник для повторного запроса. +- Когда сам Reddit не отвечает, включается ветка `reddit-archive`: поиск по [Arctic Shift](https://arctic-shift.photon-reddit.com), независимому публичному архиву постов Reddit. Это не API Reddit и не скрейпинг reddit.com: учётные данные не отправляются, аккаунт Reddit не участвует, лимит клиента не расходуется. Архив требует указать сабреддит вместе с запросом по заголовку, поэтому `registry/reddit_subreddits.yaml` сопоставляет каждой capability не более четырёх сабреддитов; `--subreddits` переопределяет список. Значение `score` в архиве это счёт на момент индексации, поэтому оно отдаётся как `score_at_archive` и никогда не используется для ранжирования. Правило хранения не меняется: строки архива это контент Reddit, и записать их в решение по-прежнему нельзя. +- Проверить учётные данные до того, как доверять результату: + + ```bash + python scripts/community_signals.py --check-reddit + ``` + + Команда печатает `ok`, `unconfigured` или `error` вместе с использованным грантом, возвращает ненулевой код на всём, кроме `ok`, и никогда не выводит сами учётные данные. + - Прочитай одну динамическую страницу через управляемый бинарник: ```bash diff --git a/registry/reddit_subreddits.yaml b/registry/reddit_subreddits.yaml new file mode 100644 index 0000000..bd3c113 --- /dev/null +++ b/registry/reddit_subreddits.yaml @@ -0,0 +1,50 @@ +# Subreddits probed through the Arctic Shift archive when Reddit's own API is +# unavailable. The archive requires a subreddit (or author) alongside a title +# query, so this list is the search surface, not a preference. +# +# Keep it short: every entry costs one archive request, paced to stay polite. +# `default` is used when no capability match is found. + +default: + - programming + - ExperiencedDevs + - devops + +capabilities: + data-pipelines: + - dataengineering + - apachekafka + workflow-orchestration: + - dataengineering + - devops + web-frontend: + - webdev + - reactjs + backend-services: + - programming + - golang + infrastructure: + - devops + - kubernetes + - selfhosted + observability: + - devops + - sre + databases: + - Database + - PostgreSQL + ml-and-inference: + - MachineLearning + - LocalLLaMA + agents-and-llm-tooling: + - LocalLLaMA + - ChatGPTCoding + mcp-servers: + - mcp + - ClaudeAI + developer-tooling: + - programming + - vscode + security: + - netsec + - sysadmin diff --git a/registry/sources.yaml b/registry/sources.yaml index f8f8870..a74eb2d 100644 --- a/registry/sources.yaml +++ b/registry/sources.yaml @@ -109,6 +109,12 @@ adapter: last30days:search operations: [search, discussion] +# Reddit is read through app-only OAuth (scripts/community_signals.py) and, when +# that is unconfigured, through the Arctic Shift archive. A logged-in browser +# session is NOT an accepted route here: driving reddit.com under a personal +# account masks automated access and mixes an app with a human account, both of +# which Reddit's Responsible Builder Policy prohibits, and the penalty lands on +# the user's own account. See .procoder/adr/0014-no-logged-in-reddit-automation.md. - id: reddit class: community capability: reddit-skill diff --git a/scripts/community_signals.py b/scripts/community_signals.py index b892add..1648467 100644 --- a/scripts/community_signals.py +++ b/scripts/community_signals.py @@ -12,14 +12,15 @@ import importlib.util import importlib import argparse -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone import json import os +import re import time import sys import urllib.error import urllib.parse -from typing import Any +from typing import Any, Sequence def _wheel_core() -> Any: @@ -48,9 +49,13 @@ def _wheel_core() -> Any: return module -_bounded_https_download = getattr(_wheel_core(), "_bounded_https_download") -_gh_api = getattr(_wheel_core(), "_gh_api") -_timestamp = getattr(_wheel_core(), "_timestamp") +# Bind from one module object: the first getattr publishes _gh_api into this +# module's globals, and when this file is __main__ a second _wheel_core() call +# would match itself by that very attribute and return the wrong module. +_core = _wheel_core() +_bounded_https_download = getattr(_core, "_bounded_https_download") +_gh_api = getattr(_core, "_gh_api") +_timestamp = getattr(_core, "_timestamp") MAX_RESPONSE_BYTES = 256 * 1024 # 256 KiB @@ -71,7 +76,6 @@ def _wheel_core() -> Any: HN_ALLOWED_HOSTS: frozenset[str] = frozenset({"hn.algolia.com"}) SEARCH_PROBE_PAUSE_SECONDS = 2.5 STACKEXCHANGE_ALLOWED_HOSTS: frozenset[str] = frozenset({"api.stackexchange.com"}) -REDDIT_ALLOWED_HOSTS: frozenset[str] = frozenset({"www.reddit.com"}) REDDIT_TOKEN_HOSTS: frozenset[str] = frozenset({"www.reddit.com"}) REDDIT_OAUTH_HOSTS: frozenset[str] = frozenset({"oauth.reddit.com"}) @@ -86,10 +90,118 @@ def _wheel_core() -> Any: _PROBE_TERMS_REGISTRY_PATH = ( Path(__file__).resolve().parents[1] / "registry" / "probe_terms.yaml" ) +_SUBREDDIT_REGISTRY_PATH = ( + Path(__file__).resolve().parents[1] / "registry" / "reddit_subreddits.yaml" +) +ARCHIVE_SEARCH_URL = "https://arctic-shift.photon-reddit.com/api/posts/search" +ARCHIVE_ALLOWED_HOSTS: frozenset[str] = frozenset({"arctic-shift.photon-reddit.com"}) +ARCHIVE_PACE_SECONDS = 2.5 +ARCHIVE_MAX_SUBREDDITS = 4 +ARCHIVE_TIMEOUT = 25.0 +ARCHIVE_LOOKBACK_DAYS = 900 +ARCHIVE_FIELDS: tuple[str, ...] = ("title", "selftext") +DEFAULT_SUBREDDITS: tuple[str, ...] = ("programming", "ExperiencedDevs", "devops") MAX_PROBE_TERMS: int = 8 +REDDIT_TOKEN_URL = "https://www.reddit.com/api/v1/access_token" +REDDIT_DEVICE_ID = "DO_NOT_TRACK_THIS_DEVICE" +REDDIT_INSTALLED_GRANT = "https://oauth.reddit.com/grants/installed_client" +REDDIT_CLIENT_ID_ENV = "WHEEL_REDDIT_CLIENT_ID" +REDDIT_CLIENT_SECRET_ENV = "WHEEL_REDDIT_CLIENT_SECRET" +REDDIT_USER_AGENT_ENV = "WHEEL_REDDIT_USER_AGENT" +REDDIT_USERNAME_ENV = "WHEEL_REDDIT_USERNAME" +REDDIT_APP_ID = "com.kalpakprod.wheel" +REDDIT_USERNAME_PATTERN = re.compile(r"^[A-Za-z0-9_-]{3,20}$") +REDDIT_RATE_RESERVE = 5 +_REDDIT_RATE_STATE: dict[str, float] = {} +_PLUGIN_MANIFEST = ( + Path(__file__).resolve().parents[1] / ".claude-plugin" / "plugin.json" +) _REDDIT_TOKEN_CACHE: dict[str, str] = {} +def _plugin_version() -> str: + """Read the shipped version so the agent string never claims a stale release.""" + try: + manifest = json.loads(_PLUGIN_MANIFEST.read_text(encoding="utf-8")) + except (OSError, ValueError): + return "0.0.0" + version = manifest.get("version") + return version if isinstance(version, str) and version else "0.0.0" + + +class RedditComplianceError(RuntimeError): + """Raised when a request would break Reddit's Data API rules if it were sent.""" + + +def reddit_user_agent() -> str: + """Build the agent string Reddit's Data API Wiki mandates, or refuse to call. + + Required shape: ``:: (by /u/)``. Reddit + states it throttles or blocks unidentified clients and that a generic agent is + limited on purpose, so an install without a contact username is not allowed to + reach the API at all rather than being throttled as an anonymous stranger. + """ + configured = os.environ.get(REDDIT_USER_AGENT_ENV, "").strip() + if configured: + return configured + username = os.environ.get(REDDIT_USERNAME_ENV, "").strip().lstrip("/") + if username.lower().startswith("u/"): + username = username[2:] + if not REDDIT_USERNAME_PATTERN.match(username): + raise RedditComplianceError( + f"Reddit requires a contact username in the User-Agent: set {REDDIT_USERNAME_ENV} " + f"to your Reddit handle, or set {REDDIT_USER_AGENT_ENV} to a full compliant string" + ) + return f"python:{REDDIT_APP_ID}:v{_plugin_version()} (by /u/{username})" + + +def _reddit_rate_gate() -> None: + """Stop before the free-tier budget runs out instead of discovering it with a 429. + + Reddit publishes 100 queries per minute per OAuth client id and asks callers to + watch the x-ratelimit headers. The last response's remaining count is kept, and + once it drops into the reserve the next call is refused until the window resets. + """ + remaining = _REDDIT_RATE_STATE.get("remaining") + reset_at = _REDDIT_RATE_STATE.get("reset_at", 0.0) + if remaining is None: + return + if remaining > REDDIT_RATE_RESERVE: + return + wait = reset_at - time.time() + if wait <= 0: + _REDDIT_RATE_STATE.clear() + return + raise RedditComplianceError( + f"Reddit rate budget exhausted: {int(remaining)} requests left, " + f"{int(wait)}s to window reset" + ) + + +def _record_reddit_rate(headers: dict[str, str]) -> None: + """Remember the published budget so the next call can honour it.""" + try: + remaining = float(headers["x-ratelimit-remaining"]) + reset = float(headers["x-ratelimit-reset"]) + except (KeyError, TypeError, ValueError): + return + _REDDIT_RATE_STATE["remaining"] = remaining + _REDDIT_RATE_STATE["reset_at"] = time.time() + reset + + +def _reddit_grant_payload(client_secret: str) -> dict[str, str]: + """Pick the grant Reddit accepts for this app type instead of guessing one. + + A script or web app is a confidential client and answers to client_credentials. + An installed app carries no secret and answers only to the installed_client grant. + The wrong grant returns 401 with no explanation, so the choice is made from the + one fact that distinguishes the two: whether a secret was configured. + """ + if client_secret: + return {"grant_type": "client_credentials"} + return {"grant_type": REDDIT_INSTALLED_GRANT, "device_id": REDDIT_DEVICE_ID} + + def load_probe_terms(registry_path: Path | None = None) -> list[str]: """Resolve probe terms across mandatory English and optional WHEEL_PROBE_LANGS. @@ -144,10 +256,11 @@ def load_probe_terms(registry_path: Path | None = None) -> list[str]: return selected_terms[:MAX_PROBE_TERMS] -def _reddit_http_request( +def _bounded_http_request( url: str, *, allowed_hosts: frozenset[str] | set[str], + user_agent: str, headers: dict[str, str] | None = None, data: bytes | None = None, max_bytes: int = 512 * 1024, @@ -171,7 +284,7 @@ def _reddit_http_request( req = urllib.request.Request( url, data=data, method="POST" if data is not None else "GET" ) - req.add_header("User-Agent", "wheel-plugin/1.0 (community-signals)") + req.add_header("User-Agent", user_agent) if headers: for k, v in headers.items(): req.add_header(k, v) @@ -203,6 +316,226 @@ def redirect_request(self, req, fp, code, msg, headers, newurl): return resp.status, body, resp_headers +def load_subreddits(capability: str | None = None) -> list[str]: + """Resolve the subreddits to probe, falling back to a built-in engineering set. + + The archive rejects a free-text title query that is not paired with a subreddit + or an author, so a search surface has to be named. pyyaml is optional on a user + machine, exactly as it is for probe terms, so its absence degrades to defaults + rather than taking the source down. + """ + path = _SUBREDDIT_REGISTRY_PATH + if not path.is_file(): + return list(DEFAULT_SUBREDDITS) + try: + import yaml + except ImportError: + return list(DEFAULT_SUBREDDITS) + try: + data = yaml.safe_load(path.read_text(encoding="utf-8")) + except (OSError, UnicodeDecodeError, yaml.YAMLError): + return list(DEFAULT_SUBREDDITS) + if not isinstance(data, dict): + return list(DEFAULT_SUBREDDITS) + chosen: list[str] = [] + mapping = data.get("capabilities") + if capability and isinstance(mapping, dict): + entry = mapping.get(capability) + if isinstance(entry, (list, tuple)): + chosen = [str(item).strip().removeprefix("r/") for item in entry] + if not chosen: + fallback = data.get("default") + if isinstance(fallback, (list, tuple)): + chosen = [str(item).strip().removeprefix("r/") for item in fallback] + names = [name for name in dict.fromkeys(chosen) if name] + return names[:ARCHIVE_MAX_SUBREDDITS] or list(DEFAULT_SUBREDDITS) + + +def _archive_cutoff(days: int = ARCHIVE_LOOKBACK_DAYS) -> str: + return (datetime.now(timezone.utc) - timedelta(days=days)).date().isoformat() + + +def _archive_request( + subreddit: str, query: str, limit: int, field: str = "title" +) -> list[dict[str, Any]]: + """One archive query. Raises on transport failure, returns rows on success. + + `field` is `title` or `selftext`: the archive matches either, and a migration + story is as likely to be in the body of a post as in its headline. + """ + if field not in ARCHIVE_FIELDS: + raise ValueError(f"unsupported archive field: {field}") + params = urllib.parse.urlencode( + { + "subreddit": subreddit, + field: query, + "limit": str(limit), + "sort": "desc", + "after": _archive_cutoff(), + } + ) + status, body, _ = _bounded_http_request( + f"{ARCHIVE_SEARCH_URL}?{params}", + allowed_hosts=ARCHIVE_ALLOWED_HOSTS, + user_agent=archive_user_agent(), + max_bytes=MAX_RESPONSE_BYTES, + timeout=ARCHIVE_TIMEOUT, + ) + if status != 200: + raise RuntimeError(f"archive returned HTTP {status}") + payload = json.loads(body.decode("utf-8")) + rows = payload.get("data") if isinstance(payload, dict) else None + if not isinstance(rows, list): + raise RuntimeError(f"archive returned no data array: {str(payload)[:120]}") + return [row for row in rows if isinstance(row, dict)] + + +def archive_user_agent() -> str: + """The archive is a third party, so it gets an honest name and a contact repo.""" + return f"wheel/{_plugin_version()} (+https://github.com/kalpakprod/wheel)" + + +def search_reddit_archive( + query: str, + limit: int = 25, + capability: str | None = None, + subreddits: Sequence[str] | None = None, +) -> dict: + """Search Reddit threads through the Arctic Shift public archive, keyless. + + This is not Reddit's Data API and not a scrape of reddit.com: Arctic Shift is an + independent public archive, so no Reddit credentials are used, no Reddit account + can be sanctioned, and Reddit's per-client budget is untouched. What it costs is + freshness and accuracy of engagement: rows are archive snapshots, so `score` is + the count at ingest time and is not used for ranking. + + Content still originates from Reddit users, so the result carries + `retention: none` and nothing here may be written into a recorded decision. + """ + bounded_limit = min(limit, MAX_ITEMS_PER_SOURCE) + names = [ + str(name).strip().removeprefix("r/") + for name in ( + subreddits if subreddits is not None else load_subreddits(capability) + ) + ] + names = [name for name in dict.fromkeys(names) if name][:ARCHIVE_MAX_SUBREDDITS] + if not names: + return { + "source": "reddit-archive", + "status": "unavailable", + "query": query, + "reason": "no subreddit to search: the archive requires one", + "retention": "none", + "subreddits": [], + "matches": [], + } + + matches: list[dict[str, Any]] = [] + seen_urls: set[str] = set() + reasons: list[str] = [] + probed = 0 + requests_made = 0 + for name in names: + empty_for_this_sub = True + for field in ARCHIVE_FIELDS: + if field == "selftext" and not empty_for_this_sub: + # The title already answered for this subreddit; a body sweep would + # cost another eight seconds to mostly repeat it. + continue + if requests_made: + time.sleep(ARCHIVE_PACE_SECONDS) + requests_made += 1 + try: + rows = _archive_request(name, query, bounded_limit, field=field) + except Exception as exc: + # The archive answers 422 "slow down" under load. One paced retry + # is the difference between a usable source and an empty one. + if "422" in str(exc) or "429" in str(exc): + time.sleep(ARCHIVE_PACE_SECONDS * 2) + try: + rows = _archive_request(name, query, bounded_limit, field=field) + except Exception as retry_exc: + reasons.append(f"r/{name} [{field}]: {retry_exc}") + continue + else: + reasons.append(f"r/{name} [{field}]: {exc}") + continue + if field == "title": + probed += 1 + if rows: + empty_for_this_sub = False + for row in rows: + permalink = row.get("permalink") + url = f"https://www.reddit.com{permalink}" if permalink else "" + if url and url in seen_urls: + continue + if url: + seen_urls.add(url) + created = row.get("created_utc") + created_at = None + if isinstance(created, (int, float)): + created_at = datetime.fromtimestamp( + created, tz=timezone.utc + ).strftime("%Y-%m-%dT%H:%M:%SZ") + matches.append( + { + "title": _truncate_title(row.get("title", "")), + "url": url, + "subreddit": row.get("subreddit"), + "score_at_archive": row.get("score"), + "created_at": created_at, + "matched_field": field, + } + ) + + if probed == 0: + return { + "source": "reddit-archive", + "status": _dead_source_status(reasons), + "query": query, + "reason": "; ".join(reasons[:3]) or "archive unreachable", + "retention": "none", + "subreddits": names, + "matches": [], + } + + return { + "source": "reddit-archive", + "status": "ok" if probed == len(names) else "partial", + "query": query, + "reason": "; ".join(reasons[:3]), + "retention": "none", + "subreddits": names, + "matches": matches[:bounded_limit], + } + + +def _reddit_http_request( + url: str, + *, + allowed_hosts: frozenset[str] | set[str], + headers: dict[str, str] | None = None, + data: bytes | None = None, + max_bytes: int = 512 * 1024, + timeout: float = 10.0, +) -> tuple[int, bytes, dict[str, str]]: + """Reddit's own hosts: identify per their rules and stay inside the budget.""" + agent = reddit_user_agent() + _reddit_rate_gate() + status, body, resp_headers = _bounded_http_request( + url, + allowed_hosts=allowed_hosts, + user_agent=agent, + headers=headers, + data=data, + max_bytes=max_bytes, + timeout=timeout, + ) + _record_reddit_rate(resp_headers) + return status, body, resp_headers + + def _truncate_title(title: Any) -> str: if not isinstance(title, str): title = str(title or "") @@ -325,7 +658,7 @@ def _get_reddit_oauth_token() -> str | None: Caches token in-memory in _REDDIT_TOKEN_CACHE for the process only. Never prints or logs credentials. """ - client_id = os.environ.get("WHEEL_REDDIT_CLIENT_ID", "").strip() + client_id = os.environ.get(REDDIT_CLIENT_ID_ENV, "").strip() if not client_id: return None @@ -333,17 +666,12 @@ def _get_reddit_oauth_token() -> str | None: if cached: return cached - client_secret = os.environ.get("WHEEL_REDDIT_CLIENT_SECRET", "").strip() + client_secret = os.environ.get(REDDIT_CLIENT_SECRET_ENV, "").strip() user_pass = f"{client_id}:{client_secret}".encode("utf-8") basic_auth = base64.b64encode(user_pass).decode("ascii") - token_url = "https://www.reddit.com/api/v1/access_token" - data = urllib.parse.urlencode( - { - "grant_type": "https://oauth.reddit.com/grants/installed_client", - "device_id": "DO_NOT_TRACK_THIS_DEVICE", - } - ).encode("utf-8") + token_url = REDDIT_TOKEN_URL + data = urllib.parse.urlencode(_reddit_grant_payload(client_secret)).encode("utf-8") headers = { "Authorization": f"Basic {basic_auth}", @@ -380,6 +708,57 @@ def _get_reddit_oauth_token() -> str | None: return token +def check_reddit_credentials() -> dict[str, Any]: + """Mint a token and report the outcome, so a misconfigured app is visible at once. + + Returns the grant that was attempted and the failure text. Credentials themselves + are never returned, printed or logged. + """ + client_id = os.environ.get(REDDIT_CLIENT_ID_ENV, "").strip() + if not client_id: + return { + "source": "reddit", + "status": "unconfigured", + "grant": "", + "reason": f"{REDDIT_CLIENT_ID_ENV} is not set", + } + client_secret = os.environ.get(REDDIT_CLIENT_SECRET_ENV, "").strip() + grant = _reddit_grant_payload(client_secret)["grant_type"] + try: + agent = reddit_user_agent() + except RedditComplianceError as exc: + return { + "source": "reddit", + "status": "unconfigured", + "grant": grant, + "reason": str(exc), + } + _REDDIT_TOKEN_CACHE.pop("access_token", None) + try: + token = _get_reddit_oauth_token() + except Exception as exc: + return { + "source": "reddit", + "status": "error", + "grant": grant, + "reason": str(exc), + } + if not token: + return { + "source": "reddit", + "status": "unconfigured", + "grant": grant, + "reason": f"{REDDIT_CLIENT_ID_ENV} is not set", + } + return { + "source": "reddit", + "status": "ok", + "grant": grant, + "user_agent": agent, + "reason": "", + } + + def _search_reddit_oauth(query: str, limit: int, token: str) -> dict: """Execute search query using Reddit OAuth API.""" params = urllib.parse.urlencode( @@ -404,97 +783,65 @@ def _search_reddit_oauth(query: str, limit: int, token: str) -> dict: return json.loads(raw_bytes.decode("utf-8")) -def _search_reddit_donsetch(query: str, limit: int) -> dict: - """Fallback reader using wheel's donsetch dynamic-page reader.""" - core = _wheel_core() - donsetch = getattr(core, "donsetch_fetch", None) - if not callable(donsetch): - raise RuntimeError("donsetch reader not available in core") - - params = urllib.parse.urlencode( - { - "q": query, - "sort": "relevance", - "limit": str(limit), - } - ) - public_url = f"https://www.reddit.com/search.json?{params}" - res = donsetch(public_url, allowed_hosts=REDDIT_ALLOWED_HOSTS) - if isinstance(res, dict): - status_field = res.get("status") - if status_field in ("blocked", "unavailable", "error"): - reason = ( - res.get("detail") - or res.get("reason") - or res.get("error") - or f"donsetch fetch returned {status_field}" - ) - raise RuntimeError(reason) - # In donsetch_fetch, on success data["content"] has the text/content - content = res.get("content") - if not content and isinstance(res.get("data"), dict): - content = res["data"].get("content") - if not content: - content = res.get("body") - if isinstance(content, bytes): - return json.loads(content.decode("utf-8")) - elif isinstance(content, str) and content.strip(): - return json.loads(content) - raise RuntimeError("donsetch returned no readable payload body") - if hasattr(res, "status_code"): - status_code = getattr(res, "status_code", 200) - if status_code != 200: - raise RuntimeError(f"HTTP {status_code}") - body = getattr(res, "body", b"") - if isinstance(body, bytes): - return json.loads(body.decode("utf-8")) - elif isinstance(body, str): - return json.loads(body) - raise RuntimeError(f"donsetch returned unexpected response type: {type(res)}") +def _reddit_blocked(query: str, reason: str) -> dict: + """Reddit is either read through OAuth or not read at all.""" + return { + "source": "reddit", + "status": "blocked", + "query": query, + "reason": reason, + "retention": "none", + "matches": [], + } def search_reddit(query: str, limit: int = 25) -> dict: - """Search Reddit public posts for regret/migration discussions. + """Search Reddit for regret and migration discussions through the Data API. + + OAuth is the only route. Reddit's Responsible Builder Policy forbids masking how + its data is reached, and its API wiki says unauthenticated traffic is blocked + outright, so an unconfigured install reports `blocked` instead of reaching the + same content through a managed page reader. - Attempts app-only OAuth first when WHEEL_REDDIT_CLIENT_ID is set. - Falls back to donsetch managed reader on unconfigured or failed OAuth. - Returns status 'blocked' or 'unavailable' on failures, never fabricated results. + Matches are returned for the caller to read now. They carry `retention: none`: + Reddit requires deleted content to be removed from any copy held, so nothing + from this source is written to disk by the decision path. """ bounded_limit = min(limit, MAX_ITEMS_PER_SOURCE) - data = None - last_error: Exception | None = None - - oauth_token = None try: oauth_token = _get_reddit_oauth_token() + except RedditComplianceError as exc: + return _reddit_blocked(query, str(exc)) except Exception as exc: - last_error = exc + return _reddit_blocked(query, str(exc)) - if oauth_token: - try: - data = _search_reddit_oauth(query, bounded_limit, oauth_token) - except Exception as exc: - last_error = exc + if not oauth_token: + return _reddit_blocked( + query, + f"{REDDIT_CLIENT_ID_ENV} is not set: register an app at " + "https://www.reddit.com/prefs/apps and configure app-only OAuth", + ) - if data is None: - try: - data = _search_reddit_donsetch(query, bounded_limit) - except Exception as exc: - last_error = exc - exc_str = str(exc) - status_val = ( - "blocked" - if ("403" in exc_str or "blocked" in exc_str.lower()) - else "unavailable" - ) - return { - "source": "reddit", - "status": status_val, - "query": query, - "reason": exc_str, - "matches": [], - } + try: + data = _search_reddit_oauth(query, bounded_limit, oauth_token) + except RedditComplianceError as exc: + return _reddit_blocked(query, str(exc)) + except Exception as exc: + exc_str = str(exc) + status_val = ( + "blocked" + if ("403" in exc_str or "429" in exc_str or "blocked" in exc_str.lower()) + else "unavailable" + ) + return { + "source": "reddit", + "status": status_val, + "query": query, + "reason": exc_str, + "retention": "none", + "matches": [], + } children = ( (data.get("data") or {}).get("children") if isinstance(data, dict) else None @@ -505,6 +852,7 @@ def search_reddit(query: str, limit: int = 25) -> dict: "status": "error", "query": query, "reason": "Missing or non-list children in payload", + "retention": "none", "matches": [], } @@ -541,6 +889,7 @@ def search_reddit(query: str, limit: int = 25) -> dict: "source": "reddit", "status": "ok", "query": query, + "retention": "none", "matches": matches, } @@ -732,6 +1081,8 @@ def collect_regret_signals( slug: str, display_name: str | None = None, limit: int = 25, + capability: str | None = None, + subreddits: Sequence[str] | None = None, ) -> dict: """Run every source and aggregate regret signals. @@ -752,6 +1103,14 @@ def collect_regret_signals( ) reddit_result = search_reddit(reddit_query, limit=limit) + # The archive is a distinct source, not a disguise for the API: it runs only + # when Reddit itself did not answer, and it says so in its own status. + archive_result = None + if reddit_result.get("status") != "ok": + archive_result = search_reddit_archive( + query_term, limit=limit, capability=capability, subreddits=subreddits + ) + # Run Stack Overflow search so_result = search_stackexchange(query_term, limit=limit) @@ -765,6 +1124,9 @@ def collect_regret_signals( "stackoverflow": len(so_result.get("matches", [])), "github_issues": len(gh_result.get("matches", [])), } + if archive_result is not None: + sources.append(archive_result) + counts["reddit_archive"] = len(archive_result.get("matches", [])) available_sources = sum(1 for src in sources if src.get("status") == "ok") @@ -784,7 +1146,12 @@ def main(argv: list[str] | None = None) -> int: description="Search Reddit, Hacker News, and GitHub issues for community regret signals.", ) parser.add_argument( - "--slug", required=True, help="GitHub repository slug (owner/repo)" + "--slug", default=None, help="GitHub repository slug (owner/repo)" + ) + parser.add_argument( + "--check-reddit", + action="store_true", + help="Verify the configured Reddit app can mint a token, then exit", ) parser.add_argument( "--display-name", @@ -797,14 +1164,45 @@ def main(argv: list[str] | None = None) -> int: default=25, help="Maximum matches per source (default 25, capped at 25)", ) + parser.add_argument( + "--capability", + default=None, + help="Capability id used to pick subreddits for the archive lane", + ) + parser.add_argument( + "--subreddits", + default=None, + help="Comma-separated subreddits for the archive lane, overriding the registry", + ) parser.add_argument("--json", action="store_true", help="Output results as JSON") args = parser.parse_args(argv) + if args.check_reddit: + report = check_reddit_credentials() + if args.json: + print(json.dumps(report, indent=2)) + else: + grant = f" ({report['grant']})" if report["grant"] else "" + print(f"reddit: {report['status']}{grant}") + if report["reason"]: + print(f" reason: {report['reason']}") + return 0 if report["status"] == "ok" else 1 + + if not args.slug: + parser.error("--slug is required unless --check-reddit is used") + + named_subreddits = ( + [name.strip() for name in args.subreddits.split(",") if name.strip()] + if args.subreddits + else None + ) results = collect_regret_signals( slug=args.slug, display_name=args.display_name, limit=args.limit, + capability=args.capability, + subreddits=named_subreddits, ) if args.json: @@ -812,7 +1210,9 @@ def main(argv: list[str] | None = None) -> int: return 0 print(f"Community Signals for {results['slug']} at {results['checked_at']}") - print(f"Available Sources: {results['available_sources']}/3") + print( + f"Available Sources: {results['available_sources']}/{len(results['sources'])}" + ) print("Counts:") for src_name, count in results["counts"].items(): print(f" - {src_name}: {count}") diff --git a/scripts/dependency_debt.py b/scripts/dependency_debt.py index ed7cbfd..4aff27f 100644 --- a/scripts/dependency_debt.py +++ b/scripts/dependency_debt.py @@ -45,7 +45,8 @@ def _wheel_core() -> Any: return module -_gh_api = getattr(_wheel_core(), "_gh_api") +_core = _wheel_core() +_gh_api = getattr(_core, "_gh_api") SUPPORTED_MANIFESTS: dict[str, str] = { diff --git a/scripts/hard_metrics.py b/scripts/hard_metrics.py index 06d72e3..50331c1 100644 --- a/scripts/hard_metrics.py +++ b/scripts/hard_metrics.py @@ -43,9 +43,13 @@ def _wheel_core() -> Any: return module -DAY_SECONDS = getattr(_wheel_core(), "DAY_SECONDS") -_gh_api = getattr(_wheel_core(), "_gh_api") -_timestamp = getattr(_wheel_core(), "_timestamp") +# Bind from one module object: the first getattr publishes _gh_api into this +# module's globals, and when this file is __main__ a second _wheel_core() call +# would match itself by that very attribute and return the wrong module. +_core = _wheel_core() +DAY_SECONDS = getattr(_core, "DAY_SECONDS") +_gh_api = getattr(_core, "_gh_api") +_timestamp = getattr(_core, "_timestamp") def bus_factor(contributors: list[dict]) -> dict: diff --git a/scripts/wheel.py b/scripts/wheel.py index 093d634..9754d20 100644 --- a/scripts/wheel.py +++ b/scripts/wheel.py @@ -2157,12 +2157,41 @@ def _reject_secret_material(value: Any, subject: str) -> None: raise ValueError(f"{subject} contains forbidden secret material") +REDDIT_CONTENT_PATTERN = re.compile( + r"(?:^|[^\w.])(?:[a-z0-9-]+\.)?reddit\.com/|(?:^|[^\w.])redd\.it/", re.IGNORECASE +) + + +def _reject_reddit_content(value: Any, subject: str) -> None: + """Keep Reddit content out of anything written to disk. + + Reddit's Data API terms require deleted posts and comments to be removed from + every copy held, including titles, bodies and embedded URLs, and state that + retaining them is a violation even when anonymized. A decision file lives in git + forever, so it cannot honour a deletion. Reddit therefore informs the verdict + during the run and is cited as a live source to re-query, never quoted into it. + """ + if isinstance(value, dict): + for nested in value.values(): + _reject_reddit_content(nested, subject) + elif isinstance(value, list): + for nested in value: + _reject_reddit_content(nested, subject) + elif isinstance(value, str) and REDDIT_CONTENT_PATTERN.search(value): + raise ValueError( + f"{subject} may not carry Reddit content: Reddit's Data API terms require " + "deleted posts to be purged from every copy, which a recorded file cannot do" + ) + + def _reject_decision_secrets(value: Any) -> None: _reject_secret_material(value, "decision") + _reject_reddit_content(value, "decision") def _reject_run_secrets(value: Any) -> None: _reject_secret_material(value, "run record") + _reject_reddit_content(value, "run record") def _decision_slug(decision: dict[str, Any]) -> str: diff --git a/skills/wheel-research/SKILL.md b/skills/wheel-research/SKILL.md index 984be97..bc2da48 100644 --- a/skills/wheel-research/SKILL.md +++ b/skills/wheel-research/SKILL.md @@ -60,7 +60,8 @@ Use the route matching the registered source: - GitTrend and Trendshift for trend signals. Their repository-page reader is `donsetch:fetch`, invoked through runtime adapter `wheel:read-url`; DonSeTch remains a reader, not a Source. - Use `donsetch:fetch` through runtime adapter `wheel:read-url` for web and documentation only when the ordinary reader has a dynamic page, JS shell, bot wall, or incomplete output. Agent Reach remains the discovery route. - `last30days`, Reddit, Telegram parser, Hacker News, X, YouTube, and V2EX for independent discussion or usage signals when available. -- OpenCLI only for a page that requires an existing authenticated user browser session. DonSeTch does not replace login-backed platform adapters, Reddit login state, or platform-specific APIs. +- OpenCLI only for a page that requires an existing authenticated user browser session. DonSeTch does not replace login-backed platform adapters or platform-specific APIs. +- Never drive Reddit through a logged-in browser session, including `opencli reddit `. Reddit's Responsible Builder Policy forbids masking automated access and mixing an app with a human account, and its enforcement suspends the account, not the tool. Reddit evidence comes from app-only OAuth or the archive lane, and from nowhere else. The write verbs of any Reddit adapter (`upvote`, `comment`, `reply`, `subscribe`) are out of scope for research at all times. After Wheel has supplied an available managed dependency and the Source URL has passed normal validation, invoke `python "/scripts/wheel.py" read-url --focus --json [--home ]`. The runtime wrapper, not a global `donsetch` command or PATH shim, invokes the managed binary without a shell and parses stdout JSON separately from stderr. Keep only a non-sensitive stderr category. A readable result requires valid JSON, `content_ok=true`, and substantive extracted text. Preserve available `quality`, escalation trace, adapter, truncation, pagination, and thin-content indicators. @@ -95,6 +96,8 @@ Begin only after `wheel-grilling` says the next answer cannot change the final C A source whose status is not `ok` (e.g. `unavailable`, `partial`, `blocked`, or `error`) must be recorded and reported as missing evidence rather than as a clean result or passing audit. - Offline catalog first: run `scripts/wheel.py search-catalog --kind --json` before live routes and seed the Candidate set from its matches, recording `age_days` and `stale` in the SourceResult. A catalog hit is a lead, never Evidence; verify it live before it counts. - The catalog is a movement index, not a mirror of GitHub. Add `--moving` when the request is about momentum or what is gaining traction; the result ranks by measured `stars_per_day` and carries `with_momentum`. When `with_momentum` is `0` the index has no history yet, so report the absence and use live routes for trend rather than treating star counts as movement. +- When Reddit's API is unavailable, `community-signals` falls back to the `reddit-archive` lane (Arctic Shift, keyless, third-party). Its rows carry `score_at_archive`, a stale snapshot, so weigh the thread's existence and title, not its number. +- Reddit is read live through app-only OAuth and never quoted into a recorded artifact: `scripts/wheel.py` rejects a decision or run record that carries a `reddit.com` or `redd.it` link, because Reddit requires deleted posts to be purged from every stored copy. Paraphrase the finding and cite Reddit as a source to re-query. - Regret mining: probe GitHub issues, Reddit, and Hacker News using negative/postmortem patterns (`"{candidate} postmortem"`, `"{candidate} migration OR replaced"`, `"{candidate} memory leak"`). Record concrete architectural pitfalls as Evidence with `signal_type="risk"`. - Transitive bloat audit: check manifests (`package.json`, `pyproject.toml`, `Cargo.toml`, `go.mod`) for dependency tree depth and zero-dep advantages. Do not penalize young repos ("hidden gems") solely for low star counts if code health, cadence, and license safety are high. - Report progress at least once per minute during a long pass and persist completed SourceResults before continuing. diff --git a/tests/test_community_signals.py b/tests/test_community_signals.py index 36d6c27..03c96e5 100644 --- a/tests/test_community_signals.py +++ b/tests/test_community_signals.py @@ -3,6 +3,7 @@ import io import json import sys +import subprocess import unittest import unittest.mock from pathlib import Path @@ -10,6 +11,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) +from scripts import community_signals from scripts.community_signals import ( NEGATIVE_TERMS, PROBE_TERMS, @@ -103,16 +105,39 @@ def test_search_hackernews_failure_status(self, mock_download): self.assertEqual(result["matches"], []) self.assertIn("500", result["reason"]) - @unittest.mock.patch("scripts.community_signals._search_reddit_donsetch") - def test_search_reddit_blocked(self, mock_donsetch): - mock_donsetch.side_effect = RuntimeError("HTTP 403 Forbidden") + @unittest.mock.patch("scripts.community_signals._get_reddit_oauth_token") + @unittest.mock.patch("scripts.community_signals._search_reddit_oauth") + def test_search_reddit_blocked(self, mock_search, mock_token): + mock_token.return_value = "t0ken" + mock_search.side_effect = RuntimeError("HTTP 403 Forbidden") result = search_reddit("ToolX") self.assertEqual(result["status"], "blocked") self.assertEqual(result["matches"], []) self.assertIn("403", result["reason"]) - @unittest.mock.patch("scripts.community_signals._search_reddit_donsetch") - def test_search_reddit_success_and_timestamp_parsing(self, mock_donsetch): + @unittest.mock.patch("scripts.community_signals._get_reddit_oauth_token") + def test_unconfigured_reddit_is_blocked_not_read_another_way(self, mock_token): + mock_token.return_value = None + result = search_reddit("ToolX") + self.assertEqual(result["status"], "blocked") + self.assertEqual(result["matches"], []) + self.assertIn("WHEEL_REDDIT_CLIENT_ID", result["reason"]) + self.assertEqual(result["retention"], "none") + + @unittest.mock.patch("scripts.community_signals._get_reddit_oauth_token") + def test_rate_budget_refusal_is_reported_as_blocked(self, mock_token): + mock_token.side_effect = community_signals.RedditComplianceError( + "Reddit rate budget exhausted: 2 requests left, 30s to window reset" + ) + result = search_reddit("ToolX") + self.assertEqual(result["status"], "blocked") + self.assertIn("rate budget", result["reason"]) + + @unittest.mock.patch("scripts.community_signals._get_reddit_oauth_token") + @unittest.mock.patch("scripts.community_signals._search_reddit_oauth") + def test_search_reddit_success_and_timestamp_parsing(self, mock_search, mock_token): + mock_token.return_value = "t0ken" + mock_donsetch = mock_search reddit_payload = { "data": { "children": [ @@ -239,13 +264,19 @@ def answer(path: str): all("tiangolo" not in path for path in calls if path.startswith("search/")) ) + @unittest.mock.patch("scripts.community_signals.search_reddit_archive") @unittest.mock.patch("scripts.community_signals.search_github_issues") @unittest.mock.patch("scripts.community_signals.search_stackexchange") @unittest.mock.patch("scripts.community_signals.search_reddit") @unittest.mock.patch("scripts.community_signals.search_hackernews") def test_collect_regret_signals_all_fail( - self, mock_hn, mock_reddit, mock_so, mock_gh + self, mock_hn, mock_reddit, mock_so, mock_gh, mock_archive ): + mock_archive.return_value = { + "source": "reddit-archive", + "status": "unavailable", + "matches": [], + } mock_hn.return_value = { "source": "hackernews", "status": "unavailable", @@ -274,7 +305,8 @@ def test_collect_regret_signals_all_fail( self.assertEqual(signals["counts"]["reddit"], 0) self.assertEqual(signals["counts"]["stackoverflow"], 0) self.assertEqual(signals["counts"]["github_issues"], 0) - self.assertEqual(len(signals["sources"]), 4) + self.assertEqual(len(signals["sources"]), 5) + self.assertEqual(signals["sources"][4]["source"], "reddit-archive") @unittest.mock.patch("scripts.community_signals._bounded_https_download") def test_stackexchange_probes_every_term_and_deduplicates(self, mock_download): @@ -408,73 +440,432 @@ def test_reddit_oauth_success_and_cache(self): self.assertEqual(res2["status"], "ok") self.assertEqual(mock_http.call_count, 1) - def test_reddit_credential_absent_fallback(self): - _REDDIT_TOKEN_CACHE.clear() - with unittest.mock.patch.dict(os.environ, {}, clear=True): - with unittest.mock.patch( - "scripts.community_signals._search_reddit_donsetch" - ) as mock_donsetch: - mock_donsetch.return_value = { - "data": { - "children": [ - { - "data": { - "title": "Postmortem ToolZ", - "permalink": "/r/dev/comments/789", - "score": 4, - "created_utc": 1681000000, - } - } - ] - } - } - res = search_reddit("ToolZ") - self.assertEqual(res["status"], "ok") - self.assertEqual(len(res["matches"]), 1) - self.assertEqual(res["matches"][0]["title"], "Postmortem ToolZ") - mock_donsetch.assert_called_once() + def test_matches_are_marked_as_not_retained(self): + payload = {"data": {"children": []}} + with unittest.mock.patch.object( + community_signals, "_get_reddit_oauth_token", return_value="t0ken" + ): + with unittest.mock.patch.object( + community_signals, "_search_reddit_oauth", return_value=payload + ): + result = search_reddit("ToolX") + self.assertEqual(result["status"], "ok") + self.assertEqual(result["retention"], "none") + + +class RedditAuthTests(unittest.TestCase): + """The Reddit app type decides the grant; the wrong one fails with a bare 401.""" + + def setUp(self) -> None: + community_signals._REDDIT_TOKEN_CACHE.pop("access_token", None) + + def tearDown(self) -> None: + community_signals._REDDIT_TOKEN_CACHE.pop("access_token", None) + + def _named(self, **extra: str) -> unittest.mock._patch_dict: + env = {"WHEEL_REDDIT_USER_AGENT": "", "WHEEL_REDDIT_USERNAME": "wheelbot"} + env.update(extra) + return unittest.mock.patch.dict(os.environ, env, clear=False) + + def test_secret_selects_client_credentials(self) -> None: + payload = community_signals._reddit_grant_payload("s3cret") + self.assertEqual(payload, {"grant_type": "client_credentials"}) + + def test_no_secret_selects_installed_client_with_device_id(self) -> None: + payload = community_signals._reddit_grant_payload("") + self.assertEqual( + payload["grant_type"], community_signals.REDDIT_INSTALLED_GRANT + ) + self.assertEqual(payload["device_id"], community_signals.REDDIT_DEVICE_ID) + + def test_token_request_sends_the_selected_grant_and_hides_credentials(self) -> None: + seen: dict[str, object] = {} + + def fake_request(url, *, allowed_hosts, headers=None, data=None, **kwargs): + seen["url"] = url + seen["data"] = data.decode("utf-8") + seen["headers"] = headers or {} + return 200, b'{"access_token": "t0ken"}', {} + + env = { + "WHEEL_REDDIT_CLIENT_ID": "id-abc", + "WHEEL_REDDIT_CLIENT_SECRET": "secret-xyz", + } + with unittest.mock.patch.dict(os.environ, env, clear=False): + with unittest.mock.patch.object( + community_signals, "_reddit_http_request", fake_request + ): + token = community_signals._get_reddit_oauth_token() + + self.assertEqual(token, "t0ken") + self.assertEqual(seen["url"], community_signals.REDDIT_TOKEN_URL) + self.assertIn("grant_type=client_credentials", seen["data"]) + self.assertNotIn("secret-xyz", str(seen["url"])) + self.assertNotIn("secret-xyz", str(seen["data"])) + self.assertTrue(seen["headers"]["Authorization"].startswith("Basic ")) + + def test_user_agent_is_overridable(self) -> None: + with unittest.mock.patch.dict( + os.environ, {"WHEEL_REDDIT_USER_AGENT": "acme/9 (by /u/acme)"}, clear=False + ): + self.assertEqual( + community_signals.reddit_user_agent(), "acme/9 (by /u/acme)" + ) + + def test_user_agent_follows_the_format_reddit_documents(self) -> None: + env = {"WHEEL_REDDIT_USER_AGENT": "", "WHEEL_REDDIT_USERNAME": "u/maxim_dev"} + with unittest.mock.patch.dict(os.environ, env, clear=False): + agent = community_signals.reddit_user_agent() + self.assertTrue(agent.startswith("python:com.kalpakprod.wheel:v")) + self.assertTrue(agent.endswith("(by /u/maxim_dev)")) + self.assertNotIn(":v0.0.0", agent) + + def test_an_unnamed_install_is_refused_rather_than_sent_anonymously(self) -> None: + env = {"WHEEL_REDDIT_USER_AGENT": "", "WHEEL_REDDIT_USERNAME": ""} + with unittest.mock.patch.dict(os.environ, env, clear=False): + with self.assertRaises(community_signals.RedditComplianceError): + community_signals.reddit_user_agent() + + def test_a_bad_username_is_refused(self) -> None: + env = {"WHEEL_REDDIT_USER_AGENT": "", "WHEEL_REDDIT_USERNAME": "no spaces!"} + with unittest.mock.patch.dict(os.environ, env, clear=False): + with self.assertRaises(community_signals.RedditComplianceError): + community_signals.reddit_user_agent() + + def test_rate_gate_refuses_once_the_published_budget_is_spent(self) -> None: + community_signals._REDDIT_RATE_STATE.clear() + try: + community_signals._record_reddit_rate( + {"x-ratelimit-remaining": "2", "x-ratelimit-reset": "45"} + ) + with self.assertRaises(community_signals.RedditComplianceError) as caught: + community_signals._reddit_rate_gate() + self.assertIn("rate budget", str(caught.exception)) + + community_signals._record_reddit_rate( + {"x-ratelimit-remaining": "80", "x-ratelimit-reset": "45"} + ) + community_signals._reddit_rate_gate() + finally: + community_signals._REDDIT_RATE_STATE.clear() + + def test_rate_state_ignores_a_response_without_the_headers(self) -> None: + community_signals._REDDIT_RATE_STATE.clear() + community_signals._record_reddit_rate({"content-type": "application/json"}) + self.assertEqual(community_signals._REDDIT_RATE_STATE, {}) + community_signals._reddit_rate_gate() + + def test_check_reports_unconfigured_without_touching_the_network(self) -> None: + def explode(*args, **kwargs): + raise AssertionError("no request may be made without a client id") - def test_reddit_credential_absent_fallback_failure(self): - _REDDIT_TOKEN_CACHE.clear() - with unittest.mock.patch.dict(os.environ, {}, clear=True): - with unittest.mock.patch( - "scripts.community_signals._search_reddit_donsetch" - ) as mock_donsetch: - mock_donsetch.side_effect = RuntimeError( - "donsetch unavailable: browser crashed" - ) - res = search_reddit("ToolZ") - self.assertEqual(res["status"], "unavailable") - self.assertIn("browser crashed", res["reason"]) - self.assertEqual(res["matches"], []) - - def test_registry_driven_term_selection(self): - terms = load_probe_terms() - self.assertIsInstance(terms, list) - self.assertIn("postmortem", terms) - self.assertIn("migrated away", terms) - self.assertLessEqual(len(terms), 8) - - def test_wheel_probe_langs_honoured(self): - with unittest.mock.patch.dict(os.environ, {"WHEEL_PROBE_LANGS": "ru,es"}): - terms = load_probe_terms() - self.assertIn("postmortem", terms) - self.assertIn("ушёл с", terms) - self.assertLessEqual(len(terms), 8) - - def test_probe_cap_enforced(self): with unittest.mock.patch.dict( - os.environ, {"WHEEL_PROBE_LANGS": "ru,es,pt,de,fr,zh,ja"} + os.environ, {"WHEEL_REDDIT_CLIENT_ID": ""}, clear=False ): - terms = load_probe_terms() - self.assertEqual(len(terms), 8) + with unittest.mock.patch.object( + community_signals, "_reddit_http_request", explode + ): + report = community_signals.check_reddit_credentials() + + self.assertEqual(report["status"], "unconfigured") + self.assertEqual(report["grant"], "") + + def test_check_reports_failure_without_leaking_the_secret(self) -> None: + def fake_request(url, *, allowed_hosts, headers=None, data=None, **kwargs): + return 401, b"", {} + + env = { + "WHEEL_REDDIT_CLIENT_ID": "id-abc", + "WHEEL_REDDIT_CLIENT_SECRET": "secret-xyz", + "WHEEL_REDDIT_USER_AGENT": "", + "WHEEL_REDDIT_USERNAME": "wheelbot", + } + with unittest.mock.patch.dict(os.environ, env, clear=False): + with unittest.mock.patch.object( + community_signals, "_reddit_http_request", fake_request + ): + report = community_signals.check_reddit_credentials() + + self.assertEqual(report["status"], "error") + self.assertEqual(report["grant"], "client_credentials") + self.assertIn("401", report["reason"]) + self.assertNotIn("secret-xyz", report["reason"]) + self.assertNotIn("id-abc", report["reason"]) + + def test_check_reports_ok_when_a_token_is_minted(self) -> None: + def fake_request(url, *, allowed_hosts, headers=None, data=None, **kwargs): + return 200, b'{"access_token": "t0ken"}', {} + + env = { + "WHEEL_REDDIT_CLIENT_ID": "id-abc", + "WHEEL_REDDIT_CLIENT_SECRET": "", + "WHEEL_REDDIT_USER_AGENT": "", + "WHEEL_REDDIT_USERNAME": "wheelbot", + } + with unittest.mock.patch.dict(os.environ, env, clear=False): + with unittest.mock.patch.object( + community_signals, "_reddit_http_request", fake_request + ): + report = community_signals.check_reddit_credentials() + + self.assertEqual(report["status"], "ok") + self.assertEqual(report["grant"], community_signals.REDDIT_INSTALLED_GRANT) + self.assertEqual(report["reason"], "") + self.assertIn("(by /u/wheelbot)", report["user_agent"]) + + def test_check_refuses_an_install_that_cannot_identify_itself(self) -> None: + env = { + "WHEEL_REDDIT_CLIENT_ID": "id-abc", + "WHEEL_REDDIT_CLIENT_SECRET": "", + "WHEEL_REDDIT_USER_AGENT": "", + "WHEEL_REDDIT_USERNAME": "", + } + with unittest.mock.patch.dict(os.environ, env, clear=False): + report = community_signals.check_reddit_credentials() + self.assertEqual(report["status"], "unconfigured") + self.assertIn("WHEEL_REDDIT_USERNAME", report["reason"]) + + +class ScriptEntryPointTests(unittest.TestCase): + """Each helper is documented as a plain script, so it must run as one.""" + + def test_every_script_runs_as_a_file_not_only_as_a_module(self) -> None: + repo = Path(__file__).resolve().parents[1] + for name in ("community_signals", "dependency_debt", "hard_metrics"): + with self.subTest(script=name): + completed = subprocess.run( + [sys.executable, str(repo / "scripts" / f"{name}.py"), "--help"], + capture_output=True, + text=True, + timeout=60, + ) + self.assertEqual(completed.returncode, 0, completed.stderr[-400:]) + - def test_registry_missing_fallback(self): - from pathlib import Path +class RedditArchiveTests(unittest.TestCase): + """The archive is a third party: no Reddit credentials, no Reddit budget.""" + + def _payload(self, rows): + return 200, json.dumps({"data": rows}).encode("utf-8"), {} + + def test_the_request_goes_to_the_archive_and_carries_no_reddit_credential( + self, + ) -> None: + seen: dict[str, object] = {} + + def fake_request(url, *, allowed_hosts, user_agent, **kwargs): + seen["url"] = url + seen["hosts"] = set(allowed_hosts) + seen["agent"] = user_agent + seen["headers"] = kwargs.get("headers") + return self._payload([]) + + env = { + "WHEEL_REDDIT_CLIENT_ID": "id-abc", + "WHEEL_REDDIT_CLIENT_SECRET": "secret-xyz", + } + with unittest.mock.patch.dict(os.environ, env, clear=False): + with unittest.mock.patch.object( + community_signals, "_bounded_http_request", fake_request + ): + result = community_signals.search_reddit_archive( + "kafka", limit=5, subreddits=["dataengineering"] + ) + + self.assertEqual(result["source"], "reddit-archive") + self.assertIn("arctic-shift.photon-reddit.com", str(seen["url"])) + self.assertEqual(seen["hosts"], {"arctic-shift.photon-reddit.com"}) + self.assertNotIn("secret-xyz", str(seen["url"])) + self.assertIsNone(seen["headers"]) + self.assertTrue(str(seen["agent"]).startswith("wheel/")) + + def test_rows_are_normalized_and_never_ranked_by_the_archived_score(self) -> None: + rows = [ + { + "title": "We migrated away from ToolX", + "permalink": "/r/dataengineering/comments/abc/we_migrated/", + "subreddit": "dataengineering", + "score": 3, + "created_utc": 1672531200, + } + ] + with unittest.mock.patch.object( + community_signals, + "_bounded_http_request", + lambda *a, **k: self._payload(rows), + ): + result = community_signals.search_reddit_archive( + "ToolX", limit=5, subreddits=["dataengineering"] + ) + + self.assertEqual(result["status"], "ok") + match = result["matches"][0] + self.assertEqual(match["matched_field"], "title") + self.assertEqual(match["created_at"], "2023-01-01T00:00:00Z") + self.assertEqual(match["score_at_archive"], 3) + self.assertNotIn("points", match) + self.assertEqual( + match["url"], + "https://www.reddit.com/r/dataengineering/comments/abc/we_migrated/", + ) + self.assertEqual(result["retention"], "none") + + def test_a_slow_down_answer_is_retried_once_then_reported(self) -> None: + calls = {"n": 0} + + def fake_request(*args, **kwargs): + calls["n"] += 1 + raise RuntimeError("archive returned HTTP 422") + + with unittest.mock.patch.object(community_signals, "time") as fake_time: + fake_time.time.return_value = 0.0 + with unittest.mock.patch.object( + community_signals, "_bounded_http_request", fake_request + ): + result = community_signals.search_reddit_archive( + "ToolX", limit=5, subreddits=["dataengineering"] + ) + + # title and selftext, each retried once after the archive says slow down + self.assertEqual(calls["n"], 4) + self.assertIn("422", result["reason"]) + self.assertEqual(result["matches"], []) + + def test_partial_when_one_subreddit_answers_and_another_does_not(self) -> None: + rows = [{"title": "t", "permalink": "/r/a/comments/1/t/", "subreddit": "a"}] + answers = [self._payload(rows), RuntimeError("archive returned HTTP 500")] + + def fake_request(*args, **kwargs): + answer = answers.pop(0) + if isinstance(answer, Exception): + raise answer + return answer + + with unittest.mock.patch.object(community_signals, "time") as fake_time: + fake_time.time.return_value = 0.0 + with unittest.mock.patch.object( + community_signals, "_bounded_http_request", fake_request + ): + result = community_signals.search_reddit_archive( + "t", limit=5, subreddits=["a", "b"] + ) + + self.assertEqual(result["status"], "partial") + self.assertEqual(len(result["matches"]), 1) + self.assertIn("500", result["reason"]) + + def test_registry_maps_a_capability_and_falls_back_to_defaults(self) -> None: + self.assertEqual( + community_signals.load_subreddits("mcp-servers"), ["mcp", "ClaudeAI"] + ) + self.assertEqual( + community_signals.load_subreddits("no-such-capability"), + list(community_signals.DEFAULT_SUBREDDITS), + ) + + def test_the_archive_lane_is_skipped_when_reddit_itself_answered(self) -> None: + ok_reddit = {"source": "reddit", "status": "ok", "matches": []} + empty = {"source": "x", "status": "error", "matches": []} + with unittest.mock.patch.object( + community_signals, "search_reddit", return_value=ok_reddit + ): + with unittest.mock.patch.object( + community_signals, "search_hackernews", return_value=empty + ): + with unittest.mock.patch.object( + community_signals, "search_stackexchange", return_value=empty + ): + with unittest.mock.patch.object( + community_signals, "search_github_issues", return_value=empty + ): + with unittest.mock.patch.object( + community_signals, "search_reddit_archive" + ) as mock_archive: + signals = collect_regret_signals("foo/bar") + + mock_archive.assert_not_called() + self.assertNotIn("reddit_archive", signals["counts"]) + + +class ArchiveFieldSweepTests(unittest.TestCase): + """A migration story lives in the body as often as in the headline.""" + + def _rows(self, permalink): + return [ + { + "title": "t", + "permalink": permalink, + "subreddit": "devops", + "created_utc": 1672531200, + } + ] + + def test_the_body_is_swept_only_when_the_title_found_nothing(self) -> None: + seen: list[str] = [] + + def fake_request(url, **kwargs): + seen.append("selftext=" if "selftext=" in url else "title=") + rows = [] if "title=" in url else self._rows("/r/devops/comments/2/b/") + return 200, json.dumps({"data": rows}).encode("utf-8"), {} + + with unittest.mock.patch.object(community_signals, "time") as fake_time: + fake_time.time.return_value = 0.0 + with unittest.mock.patch.object( + community_signals, "_bounded_http_request", fake_request + ): + result = community_signals.search_reddit_archive( + "ToolX", limit=5, subreddits=["devops"] + ) + + self.assertEqual(seen, ["title=", "selftext="]) + self.assertEqual(result["matches"][0]["matched_field"], "selftext") + + def test_a_title_hit_skips_the_body_sweep(self) -> None: + seen: list[str] = [] + + def fake_request(url, **kwargs): + seen.append("selftext=" if "selftext=" in url else "title=") + return ( + 200, + json.dumps({"data": self._rows("/r/devops/comments/1/a/")}).encode( + "utf-8" + ), + {}, + ) + + with unittest.mock.patch.object(community_signals, "time") as fake_time: + fake_time.time.return_value = 0.0 + with unittest.mock.patch.object( + community_signals, "_bounded_http_request", fake_request + ): + result = community_signals.search_reddit_archive( + "ToolX", limit=5, subreddits=["devops"] + ) + + self.assertEqual(seen, ["title="]) + self.assertEqual(len(result["matches"]), 1) + + def test_the_same_thread_is_not_reported_twice(self) -> None: + def fake_request(url, **kwargs): + rows = [] if "title=" in url else self._rows("/r/devops/comments/3/c/") + if "selftext=" in url: + rows = rows + rows + return 200, json.dumps({"data": rows}).encode("utf-8"), {} + + with unittest.mock.patch.object(community_signals, "time") as fake_time: + fake_time.time.return_value = 0.0 + with unittest.mock.patch.object( + community_signals, "_bounded_http_request", fake_request + ): + result = community_signals.search_reddit_archive( + "ToolX", limit=5, subreddits=["devops"] + ) + + self.assertEqual(len(result["matches"]), 1) - missing_path = Path("this_file_does_not_exist_xyz.yaml") - terms = load_probe_terms(registry_path=missing_path) - self.assertEqual(terms, list(PROBE_TERMS[:8])) + def test_an_unsupported_field_is_refused(self) -> None: + with self.assertRaises(ValueError): + community_signals._archive_request("devops", "x", 5, field="author") if __name__ == "__main__": diff --git a/tests/test_wheel.py b/tests/test_wheel.py index baa353c..e26dfb7 100644 --- a/tests/test_wheel.py +++ b/tests/test_wheel.py @@ -2296,5 +2296,58 @@ def test_cli_subcommands_dispatch_lazy(self) -> None: mock_hard.assert_called_once_with("test/repo") +class RedditRetentionTests(unittest.TestCase): + """A recorded file cannot honour a Reddit deletion, so it may not hold one.""" + + def _decision(self, verdict: str) -> dict: + return { + "accepted": True, + "task": "pick a queue", + "coverage": "full", + "implementation_mode": "adopt", + "verdict": verdict, + "picked": "owner/repo", + "maturity": "proven", + "date": "2026-09-04", + "core": "queue", + "upstream_or_fork": "upstream", + "candidates": [{"id": "owner/repo", "role": "base"}], + "evidence": [], + "risks": [], + "gaps": [], + "next_steps": [], + "rejected_reasons": [], + "sources": [], + } + + def test_a_reddit_permalink_is_refused_in_a_decision(self) -> None: + decision = self._decision( + "https://www.reddit.com/r/programming/comments/1/leaving_toolx says it leaks" + ) + with self.assertRaisesRegex(ValueError, "Reddit content"): + wheel._reject_decision_secrets(decision) + + def test_a_short_reddit_link_is_refused_too(self) -> None: + decision = self._decision("see https://redd.it/abc123 for the postmortem") + with self.assertRaisesRegex(ValueError, "Reddit content"): + wheel._reject_decision_secrets(decision) + + def test_a_run_record_may_not_carry_reddit_content(self) -> None: + with self.assertRaisesRegex(ValueError, "Reddit content"): + wheel._reject_run_secrets( + {"notes": ["old.reddit.com/r/rust/comments/9/why_we_left"]} + ) + + def test_a_paraphrased_finding_without_a_link_is_allowed(self) -> None: + decision = self._decision( + "Reddit threads report memory growth under load; re-query to read them" + ) + wheel._reject_decision_secrets(decision) + + def test_an_unrelated_host_is_not_caught_by_the_pattern(self) -> None: + decision = self._decision("https://notreddit.company.dev/blog/why-we-migrated") + wheel._reject_decision_secrets(decision) + + if __name__ == "__main__": unittest.main()