Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <article_id>`** -- 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
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <article_id>` | Read the threaded comments on an article (read-only) |

### 🔧 Utilities

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
Expand Down
2 changes: 1 addition & 1 deletion src/devpub/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
"""devpub - Write in your editor. Publish to Dev.to. Track what works."""

__version__ = "0.3.0"
__version__ = "0.4.0"
25 changes: 25 additions & 0 deletions src/devpub/api/devto.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Expand Down
18 changes: 18 additions & 0 deletions src/devpub/cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
116 changes: 116 additions & 0 deletions src/devpub/cli/comments.py
Original file line number Diff line number Diff line change
@@ -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"</(p|div|br|li)>", "\n", html, flags=re.IGNORECASE)
text = re.sub(r"<br\s*/?>", "\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)
65 changes: 65 additions & 0 deletions tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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": "<p>Great post!</p>",
"created_at": "2026-09-01T10:00:00Z",
"user": {"username": "reader1", "name": "Reader One"},
"children": [
{
"type_of": "comment",
"id_code": "3c4d",
"body_html": "<p>Agreed.</p>",
"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": "<p>Hi</p>", "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):
Expand Down
52 changes: 52 additions & 0 deletions tests/test_comments.py
Original file line number Diff line number Diff line change
@@ -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("<p>Hello <strong>world</strong></p>") == "Hello world"

def test_unescapes_entities(self):
assert _html_to_text("<p>a &amp; b &lt; c</p>") == "a & b < c"

def test_paragraphs_become_newlines(self):
result = _html_to_text("<p>First</p><p>Second</p>")
assert result == "First\nSecond"

def test_br_becomes_newline(self):
assert _html_to_text("one<br>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("<p>a</p><p></p><p>b</p>")
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
Loading