feat(devto): surface article id on listings + new read <id>#1292
Merged
feat(devto): surface article id on listings + new read <id>#1292
read <id>#1292Conversation
… <id>` Agent-native gap: devto listings (`top`/`tag`/`user`) didn't include the article `id`, so an agent couldn't round-trip from a listing into a body read. They also dropped `reading_time` and `published_at`, which are cheap signals the API gives you for free. Changes: - `top` / `tag` / `user`: add `id`, `reading_time`, `published_at` columns alongside existing rank/title/etc. `user` keeps its no-author shape since it's already user-scoped. - New `devto read <id>`: hits `dev.to/api/articles/<id>` and returns one row with the article body (truncated by `--max-length`, default 20000, min 100). DEV.to's public API does not expose comments yet, so this is intentionally a single-row reader rather than a HN/lobsters-style threaded tree — if/when comments become public we can extend to POST + L0/L1. - Typed fail-fast: `ArgumentError` for non-numeric id and for `--max-length` below 100; `EmptyResultError` on 404; `CommandExecutionError` for other non-2xx HTTP statuses. No silent clamps. - Defensive tag normalization: the `/api/articles/<id>` endpoint returns `tag_list` as a comma-string and `tags` as an array (the opposite shape from listing endpoints). Caught this on live verification — both shapes now collapse to a comma-joined string. Tests: 12 vitest assertions covering listing column shape (all 3) + register/args/strategy + typed-error fail-fast paths + happy-path body extraction + truncation marker + alternate tag_list shape. Live verification: `devto top --limit 3` and `devto read 3602287` both return the expected agent-native shape.
jackwener
added a commit
that referenced
this pull request
May 4, 2026
Apply the 3 lessons from PR #1292 (devto) review at merge time, before B-group hits this PR: 1. CLI args may arrive as strings (e.g. `--max-length 50` → `'50'`). The bare `Number.isInteger(value)` in `requirePositiveInt` / `requireMinInt` would accept negative-but-coerced numbers and reject string-form integers. Now the helpers `coerceInt` first then validate, and the rejection message echoes the raw input via `JSON.stringify`. 2. `await fetch(url)` and `await res.json()` were not wrapped — a network blip would surface as a raw `TypeError` and a maintenance HTML page would surface as a raw `SyntaxError`. Both are now caught and rethrown as `CommandExecutionError` with hints, matching the in-band error_id path. Tests: +3 cases (17 total) - fetch network failure → CommandExecutionError - malformed JSON body → CommandExecutionError - string-form max-length "50" / "abc" rejected with ArgumentError before fetching
jackwener
added a commit
that referenced
this pull request
May 4, 2026
#1293) * feat(stackoverflow): surface question_id + metadata on listings, add `read <id>` Agent-native gap: all 4 stackoverflow listings (`hot`, `search`, `unanswered`, `bounties`) only emitted `[title, score, answers, url]`, which means an agent could see a hot question but had no `id` to round- trip into a body read, no `tags` to filter by topic, no `views` to gauge demand, and no `is_answered` / `creation_date` / `author` to triage. There also wasn't a `read` adapter, so reading a SO question through opencli was impossible. Listings (`hot` / `search` / `bounties` / `unanswered`): - Add `rank`, `id` (question_id), `views`, `is_answered` (skipped on `unanswered` since always false), `tags` (joined), `author` (owner.display_name), `creation_date` columns. - Pass `pagesize` to the upstream API instead of fetching the default page and trimming locally. New `stackoverflow read <id>`: - 4-call fan-out against the public Stack Exchange API (`/questions/{id}` + `/questions/{id}/comments` + `/questions/{id}/answers` + batched `/answers/a;b;c/comments`). - Returns `POST` + `Q-COMMENT` + `ANSWER` + `A-COMMENT` rows mirroring the `hackernews read` and `lobsters read` shape. - Accepted answer is always surfaced first and tagged `accepted='true'`; remaining answers follow in descending vote order, capped by `--answers-limit`. - HTML body cleanup: tags stripped, `<pre><code>` preserved, `<code>` inline-fenced, `<li>` rendered as `- `, comments indented with `> `. - Entity decoding: a shared `decodeEntities` handles named (incl. `…`/`©`/etc), decimal (`ö`), and hex (`'`) forms, applied to both bodies AND `display_name` (otherwise users like `Jonas Kölker` come through mojibaked). - Typed fail-fast: `ArgumentError` for non-numeric id and `--max-length < 100` (with no-fetch assertion); `EmptyResultError` when `items` is empty; `CommandExecutionError` for HTTP non-2xx and for Stack Exchange's in-band `error_id` envelopes (throttle / quota). No silent clamps anywhere. Tests: 14 vitest assertions - 4 listing column-shape (incl. `unanswered` skipping `is_answered` and `bounties` keeping its `bounty` column position) - 10 read-adapter cases: registration / args / strategy + 3 typed-error fail-fast paths (with no-fetch assertion on the pre-fetch ones) + the full POST/Q-COMMENT/ANSWER/A-COMMENT row order with accepted-first + the answer-comments fetch verified to batch ids semicolon-joined + HTML entity decoding (named/decimal/hex) on both body and display_name + answers-limit honored when there are more answers than the cap. Live verification: - `stackoverflow hot --limit 2` → `id`/`tags`/`views`/`is_answered`/ `author` populated. - `stackoverflow search "async await" --limit 1`, `stackoverflow unanswered --limit 1` → same shape. - `stackoverflow read 79935770` and the very-long classic question `stackoverflow read 11227809 --answers-limit 1 --comments-limit 2` → produces the threaded POST/Q-COMMENT/ANSWER/A-COMMENT structure with proper entity decoding (`Jonas Kölker` reads correctly). - `stackoverflow read not-numeric` → exits with `ARGUMENT`. - `stackoverflow read 999999999` → exits with `EMPTY_RESULT`. * fix(stackoverflow): wrap fetch/json/coerce paths in typed errors Apply the 3 lessons from PR #1292 (devto) review at merge time, before B-group hits this PR: 1. CLI args may arrive as strings (e.g. `--max-length 50` → `'50'`). The bare `Number.isInteger(value)` in `requirePositiveInt` / `requireMinInt` would accept negative-but-coerced numbers and reject string-form integers. Now the helpers `coerceInt` first then validate, and the rejection message echoes the raw input via `JSON.stringify`. 2. `await fetch(url)` and `await res.json()` were not wrapped — a network blip would surface as a raw `TypeError` and a maintenance HTML page would surface as a raw `SyntaxError`. Both are now caught and rethrown as `CommandExecutionError` with hints, matching the in-band error_id path. Tests: +3 cases (17 total) - fetch network failure → CommandExecutionError - malformed JSON body → CommandExecutionError - string-form max-length "50" / "abc" rejected with ArgumentError before fetching * fix(stackoverflow): avoid partial read fanout
9 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
top/tag/user) didn't include the articleid, so an agent couldn't round-trip from a listing into a body read. They also droppedreading_timeandpublished_at, which are cheap signals the API gives you for free. All three now exposeid,reading_time,published_aton every row.devto read <id>hitsdev.to/api/articles/<id>and returns one row with the article body (truncated by--max-length, default 20000, min 100). DEV.to's public API does not expose comments, so this is intentionally a single-row reader rather than a HN/lobsters-style threaded tree.ArgumentErrorfor non-numeric id and for--max-lengthbelow 100;EmptyResultErroron 404;CommandExecutionErrorfor other non-2xx HTTP statuses. No silent clamps./api/articles/<id>endpoint returnstag_listas a comma-string andtagsas an array (opposite of the listing endpoints). Both shapes now collapse to a comma-joined string.Test plan
npx vitest run clis/devto/devto.test.js— 12 assertions passtop/tag/user)readregistration / args / strategyArgumentErrorno-fetch on non-numeric idArgumentErrorno-fetch onmax-length < 100EmptyResultErroron 404body_markdownmax-lengthtag_listshape (array)opencli devto top --limit 3 -f json→ id/published_at/reading_time presentopencli devto read 3602287 --max-length 400 -f json→ body extracted, tags comma-joinedopencli devto read 99999999→ exits withEMPTY_RESULTopencli devto read not-a-number→ exits withARGUMENT🤖 Generated with Claude Code