feat(stackoverflow): surface question_id on listings + new read <id>#1293
Merged
feat(stackoverflow): surface question_id on listings + new read <id>#1293
read <id>#1293Conversation
…`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`.
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
This was referenced May 4, 2026
jackwener
added a commit
that referenced
this pull request
May 4, 2026
…it (not 'all') (#1295) Follow-up from PR #1293 review: 'all answers' was misleading because the implementation is limit-bounded (default 10, max 100) rather than unbounded pagination. Spell out the actual contract — including the accepted-answer-outside-page fallback path — so users don't expect infinite-scroll behaviour. Non-blocking docs-only change flagged by codex-mini1 + First-principles-1 during #1293 review.
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
hot/search/unanswered/bounties) only emitted[title, score, answers, url]. Noidto round-trip into a body read, notagsto filter by topic, noviews/is_answered/creation_date/authorfor triage. Now they exposerank,id(question_id),views,is_answered(omitted onunansweredsince always false),tags,author,creation_date.stackoverflow read <id>fans out against the public Stack Exchange API (/questions/{id}+/questions/{id}/comments+/questions/{id}/answers+ batched/answers/a;b;c/comments). ReturnsPOST/Q-COMMENT/ANSWER/A-COMMENTrows mirroring thehackernews readandlobsters readshape. Accepted answer surfaced first withaccepted='true'.<pre><code>preserved,<code>inline-fenced,<li>→-, tags stripped. A shareddecodeEntitieshandles named / decimal / hex entities, applied to both body anddisplay_name(otherwise users likeJonas Kölkercome through mojibaked).ArgumentErrorfor non-numeric id and--max-length < 100;EmptyResultErrorwhenitemsis empty;CommandExecutionErrorfor HTTP non-2xx and for Stack Exchange's in-banderror_idenvelopes (throttle / quota). No silent clamps.Test plan
npx vitest run clis/stackoverflow/stackoverflow.test.js— 14 assertions passunansweredskippingis_answered,bountieskeepingbountycolumn)ArgumentErrorno-fetch on non-numeric idArgumentErrorno-fetch onmax-length < 100EmptyResultErroron empty itemsCommandExecutionErroron Stack Exchange error envelope (throttle)a;b(single API call)--answers-limithonored when there are more answersstackoverflow hot --limit 2 -f json→ id/tags/views/is_answered/author populatedstackoverflow search "async await" --limit 1→ same agent-native shapestackoverflow unanswered --limit 1→ no is_answered (always false)stackoverflow read 11227809 --answers-limit 1 --comments-limit 2→ threaded structure withJonas Kölkerdecoded properlystackoverflow read not-numeric→ARGUMENTstackoverflow read 999999999→EMPTY_RESULTQuota notes
Stack Exchange API has a 300/day quota per IP for unauthenticated requests. A
readcall uses up to 4 quota units (question + question comments + answers + batched answer comments). Listings cost 1 each.🤖 Generated with Claude Code