diff --git a/CHANGELOG.md b/CHANGELOG.md index 79383e1..9b4bbc8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.4.0] - 2026-10-09 + +### Added + +- **`devpub comments `** -- read the threaded comments on a Dev.to + article directly in the terminal. Replies are shown nested under their + parent, with the author, date, and comment `id_code`. Supports `--page` and + `--limit` for paging top-level comment threads. Read-only (uses the + API-key/public `GET /comments` endpoint); posting replies is not yet + supported because the Forem V1 API exposes no comment write endpoint. + ## [0.3.0] - 2026-08-27 ### Added diff --git a/README.md b/README.md index a1f3ce7..13285a8 100644 --- a/README.md +++ b/README.md @@ -126,6 +126,7 @@ devpub stats | `devpub trends` | Trending topics on Dev.to right now | | `devpub search "query"` | Keyword search for articles | | `devpub search "query" --semantic` | AI-powered search by meaning (not just keywords) | +| `devpub comments ` | Read the threaded comments on an article (read-only) | ### 🔧 Utilities diff --git a/pyproject.toml b/pyproject.toml index cd5da55..ac311fb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "devpub" -version = "0.3.0" +version = "0.4.0" description = "Write in your editor. Publish to Dev.to. Track what works." readme = "README.md" license = {text = "MIT"} diff --git a/src/devpub/__init__.py b/src/devpub/__init__.py index a87bbdb..4207a1f 100644 --- a/src/devpub/__init__.py +++ b/src/devpub/__init__.py @@ -1,3 +1,3 @@ """devpub - Write in your editor. Publish to Dev.to. Track what works.""" -__version__ = "0.3.0" +__version__ = "0.4.0" diff --git a/src/devpub/api/devto.py b/src/devpub/api/devto.py index 261c1d2..09de09f 100644 --- a/src/devpub/api/devto.py +++ b/src/devpub/api/devto.py @@ -354,6 +354,31 @@ def toggle_reaction(self, category: str, reactable_id: int, reactable_type: str) ) return resp.json() + # --- Comments --- + + def get_comments(self, article_id: int, page: int = 1, per_page: int = 30) -> list[dict]: + """Get comments for an article as a threaded tree. + + Each top-level comment carries its replies nested under ``children``. + The ``page`` parameter paginates top-level comments only; replies to + those comments are always returned inline regardless of page. + """ + resp = self._request( + "GET", + "/comments", + params={"a_id": article_id, "page": page, "per_page": per_page}, + ) + return resp.json() + + def get_comment(self, id_code: str) -> dict: + """Get a single comment and its nested replies by its ``id_code``. + + Note the identifier is the alpha-numeric ``id_code`` (e.g. ``1a2b``), + not a numeric database ID. + """ + resp = self._request("GET", f"/comments/{id_code}") + return resp.json() + # --- Reading List --- def get_readinglist(self, page: int = 1, per_page: int = 30) -> list[dict]: diff --git a/src/devpub/cli/__init__.py b/src/devpub/cli/__init__.py index ab9db9e..78cb96e 100644 --- a/src/devpub/cli/__init__.py +++ b/src/devpub/cli/__init__.py @@ -145,6 +145,24 @@ def search(query, semantic, limit): search_articles(query=query, semantic=semantic, limit=limit) +@cli.command() +@click.argument("article_id", type=int) +@click.option("--page", "-p", default=1, help="Page of top-level comments.") +@click.option("--limit", "-l", default=30, help="Top-level comments per page.") +def comments(article_id, page, limit): + """Read the threaded comments on a Dev.to article. + + \b + Pass the numeric article ID (see `devpub status` or a pulled article's + devto_id). Replies are shown nested under their parent. This is read-only: + posting replies is not yet supported because the Forem API has no comment + write endpoint. + """ + from devpub.cli.comments import show_comments + + show_comments(article_id=article_id, page=page, limit=limit) + + @cli.command() def whoami(): """Show your Dev.to profile info.""" diff --git a/src/devpub/cli/comments.py b/src/devpub/cli/comments.py new file mode 100644 index 0000000..6ba4666 --- /dev/null +++ b/src/devpub/cli/comments.py @@ -0,0 +1,116 @@ +"""Comment-reading CLI commands for devpub. + +Reads the threaded comments on an article through the Forem V1 ``/comments`` +endpoint, which is API-key (and largely public) accessible and read-only. + +Posting a reply is deliberately not here: Forem V1 exposes no write endpoint +for comments, so it would require the session-cookie bridge used by +``devpub upload``. That is tracked separately and pending a verification spike. +""" + +import re +from html import unescape + +from rich.console import Console + +from devpub.api.devto import APIError, DevtoClient +from devpub.core.config import ensure_api_key + +# Indentation applied per nesting level when printing a reply thread. +_INDENT = " " +# Cap nesting so a deep thread cannot walk indentation off the screen. +_MAX_DEPTH = 6 + +_TAG_RE = re.compile(r"<[^>]+>") +_WS_RE = re.compile(r"[ \t]*\n[ \t]*") +_BLANK_LINES_RE = re.compile(r"\n{2,}") + + +def _html_to_text(html: str) -> str: + """Reduce a comment's ``body_html`` to readable plain text. + + The API returns rendered HTML; there is no plain-text field. This is a + deliberately small reducer (strip tags, unescape entities, collapse blank + lines) rather than a full HTML parser, which would be a new dependency for + little gain in a terminal. + """ + if not html: + return "" + # Turn block boundaries into newlines before stripping tags so paragraphs + # do not run together. + text = re.sub(r"", "\n", html, flags=re.IGNORECASE) + text = re.sub(r"", "\n", text, flags=re.IGNORECASE) + text = _TAG_RE.sub("", text) + text = unescape(text) + text = _WS_RE.sub("\n", text) + text = _BLANK_LINES_RE.sub("\n", text) + return text.strip() + + +def _render(comment: dict, console: Console, depth: int = 0): + """Print one comment and recurse into its children.""" + indent = _INDENT * min(depth, _MAX_DEPTH) + user = comment.get("user") or {} + username = user.get("username") or user.get("name") or "unknown" + created = (comment.get("created_at") or "")[:10] + id_code = comment.get("id_code", "") + + header = f"{indent}[bold cyan]@{username}[/]" + if created: + header += f" [dim]· {created}[/]" + if id_code: + header += f" [dim]· {id_code}[/]" + console.print(header) + + body = _html_to_text(comment.get("body_html", "")) + if body: + for line in body.splitlines(): + console.print(f"{indent} {line}") + else: + console.print(f"{indent} [dim](no text)[/]") + console.print() + + for child in comment.get("children") or []: + _render(child, console, depth + 1) + + +def _count(comments: list) -> int: + """Count every comment in the tree, replies included.""" + total = 0 + for c in comments: + total += 1 + _count(c.get("children") or []) + return total + + +def show_comments(article_id: int, page: int = 1, limit: int = 30): + """Show the threaded comments on a Dev.to article. + + Args: + article_id: Numeric ID of the article to read comments from. + page: Page of top-level comments (replies are always inline). + limit: Top-level comments per page. + """ + console = Console() + api_key = ensure_api_key() + + try: + with DevtoClient(api_key) as client: + comments = client.get_comments(article_id, page=page, per_page=limit) + except APIError as e: + console.print(f"[red]Error: {e.message}[/]") + return + + if not comments: + console.print( + f"[yellow]No comments found on article {article_id}.[/]" + ) + return + + total = _count(comments) + console.print( + f"\n[bold]Comments on article {article_id}[/] " + f"[dim]({len(comments)} threads, {total} total)[/]\n" + ) + + for comment in comments: + _render(comment, console) diff --git a/tests/test_api.py b/tests/test_api.py index 550d423..c724025 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -155,6 +155,71 @@ def test_get_trends(self): assert result[0]["name"] == "AI" +class TestCommentsEndpoints: + @respx.mock + def test_get_comments(self): + tree = [ + { + "type_of": "comment", + "id_code": "1a2b", + "body_html": "

Great post!

", + "created_at": "2026-09-01T10:00:00Z", + "user": {"username": "reader1", "name": "Reader One"}, + "children": [ + { + "type_of": "comment", + "id_code": "3c4d", + "body_html": "

Agreed.

", + "created_at": "2026-09-01T11:00:00Z", + "user": {"username": "reader2"}, + "children": [], + } + ], + } + ] + respx.get("https://dev.to/api/comments").mock( + return_value=httpx.Response(200, json=tree) + ) + with DevtoClient(api_key="test-key") as client: + result = client.get_comments(12345) + assert result[0]["id_code"] == "1a2b" + assert result[0]["children"][0]["id_code"] == "3c4d" + + @respx.mock + def test_get_comments_passes_article_id(self): + route = respx.get("https://dev.to/api/comments").mock( + return_value=httpx.Response(200, json=[]) + ) + with DevtoClient(api_key="test-key") as client: + client.get_comments(999, page=2, per_page=10) + assert route.called + request = route.calls.last.request + assert request.url.params["a_id"] == "999" + assert request.url.params["page"] == "2" + assert request.url.params["per_page"] == "10" + + @respx.mock + def test_get_comment_by_id_code(self): + respx.get("https://dev.to/api/comments/1a2b").mock( + return_value=httpx.Response( + 200, json={"id_code": "1a2b", "body_html": "

Hi

", "children": []} + ) + ) + with DevtoClient(api_key="test-key") as client: + result = client.get_comment("1a2b") + assert result["id_code"] == "1a2b" + + @respx.mock + def test_get_comments_404(self): + respx.get("https://dev.to/api/comments").mock( + return_value=httpx.Response(404, json={"error": "not found"}) + ) + with DevtoClient(api_key="test-key") as client: + with pytest.raises(APIError) as exc_info: + client.get_comments(404404) + assert exc_info.value.status_code == 404 + + class TestUserEndpoints: @respx.mock def test_get_me(self): diff --git a/tests/test_comments.py b/tests/test_comments.py new file mode 100644 index 0000000..a61592b --- /dev/null +++ b/tests/test_comments.py @@ -0,0 +1,52 @@ +"""Tests for the comments CLI helpers.""" + +from devpub.cli.comments import _count, _html_to_text + + +class TestHtmlToText: + def test_strips_tags(self): + assert _html_to_text("

Hello world

") == "Hello world" + + def test_unescapes_entities(self): + assert _html_to_text("

a & b < c

") == "a & b < c" + + def test_paragraphs_become_newlines(self): + result = _html_to_text("

First

Second

") + assert result == "First\nSecond" + + def test_br_becomes_newline(self): + assert _html_to_text("one
two") == "one\ntwo" + + def test_empty_input(self): + assert _html_to_text("") == "" + assert _html_to_text(None) == "" + + def test_collapses_blank_lines(self): + # Multiple block closes should not produce runs of blank lines. + result = _html_to_text("

a

b

") + assert "\n\n" not in result + assert result == "a\nb" + + +class TestCount: + def test_flat(self): + comments = [{"children": []}, {"children": []}] + assert _count(comments) == 2 + + def test_nested(self): + comments = [ + { + "children": [ + {"children": []}, + {"children": [{"children": []}]}, + ] + } + ] + # 1 top-level + 2 replies + 1 nested reply = 4 + assert _count(comments) == 4 + + def test_missing_children_key(self): + assert _count([{}, {}]) == 2 + + def test_empty(self): + assert _count([]) == 0