Skip to content
Open
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
116 changes: 108 additions & 8 deletions src/planecli/commands/comments.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

from __future__ import annotations

import html
import re
from typing import Annotated

import cyclopts
Expand Down Expand Up @@ -30,6 +32,86 @@
]


_URL_RE = re.compile(r"(?<![\"'>=])(https?://[A-Za-z0-9\-._~:/?#\[\]@!$&()*+,;=%]+)")
# Trailing punctuation that belongs to the sentence, not the URL.
_URL_TRAIL = ".,;:!?)]}。,;:!?)】、"


def _linkify(text: str) -> str:
"""Turn bare http(s) URLs into anchors.

The API stores comment HTML verbatim and does not auto-link URLs — only
the web editor does — so links posted via the CLI would render as plain
text. URLs already inside an href attribute are left alone.
"""

def _sub(m: re.Match[str]) -> str:
url = m.group(1).rstrip(_URL_TRAIL)
trail = m.group(1)[len(url) :]
return f'<a href="{url}">{url}</a>{trail}'

return _URL_RE.sub(_sub, text)


def _inline_code(text: str) -> str:
"""Convert `inline code` to a code tag with HTML-escaped content."""
return re.sub(
r"`([^`\n]+)`",
lambda m: f"<code>{html.escape(m.group(1))}</code>",
text,
)


def _extract_code_blocks(text: str) -> tuple[str, list[str]]:
"""Pull fenced code blocks out of the text, replacing each with a token.

Returns the text with \x00N\x00 placeholders and the list of HTML
fragments (pre-wrapped escaped code) to restore at the end. Extracting
first keeps their content out of linkify and the newline-to-br pass.
"""
blocks: list[str] = []

def _hold(match: re.Match[str]) -> str:
code = html.escape(match.group(1).strip("\n"))
blocks.append(f"<pre><code>{code}</code></pre>")
return f"\x00{len(blocks) - 1}\x00"

text = re.sub(
r"```[ \t]*\w*[ \t]*\n(.*?)```",
_hold,
text,
flags=re.DOTALL,
)
return text, blocks


def _body_to_html(body: str) -> str:
"""Convert plain comment text to HTML paragraphs.

Blank lines separate paragraphs; a single newline becomes a br tag —
the editor collapses whitespace inside a paragraph, so unconverted
newlines would render as one long line. Backticks become code tags and
fenced blocks become pre-wrapped code (the editor stores HTML, it does
not parse markdown).
"""
text, blocks = _extract_code_blocks(body.strip())
parts: list[str] = []
for p in re.split(r"\n\s*\n", text):
p = p.strip()
if not p:
continue
converted = _linkify(_inline_code(p)).replace(chr(10), "<br/>")
if re.fullmatch(r"(?:\x00\d+\x00[ \t]*)+", p):
# A paragraph that is only a code block keeps its pre wrapper.
parts.append(converted)
else:
parts.append(f"<p>{converted}</p>")
result = "".join(parts)
for i, fragment in enumerate(blocks):
result = result.replace(f"\x00{i}\x00", fragment)
return result


def _enrich_comment(data: dict, members_map: dict[str, str] | None = None) -> dict:
"""Add convenience fields to a comment dict.

Expand All @@ -48,16 +130,15 @@ def _enrich_comment(data: dict, members_map: dict[str, str] | None = None) -> di
body_html = data.get("comment_html") or ""
if body_html:
import re

data["body_text"] = re.sub(r"<[^>]+>", "", body_html).strip()
else:
data["body_text"] = ""

return data


async def fetch_issue_comments(
workspace: str, project_id: str, item_id: str
) -> list[dict]:
async def fetch_issue_comments(workspace: str, project_id: str, item_id: str) -> list[dict]:
"""Fetch, enrich, and chronologically sort all comments for a work item.

Single source of truth shared by `comment ls` and `wi show`. Resolves the
Expand Down Expand Up @@ -111,6 +192,7 @@ async def list_(

if project:
from planecli.utils.resolve import resolve_project_async

proj = await resolve_project_async(project, client, workspace)
project_id = proj["id"]
item = await resolve_work_item_async(issue, client, workspace, project_id)
Expand Down Expand Up @@ -159,6 +241,7 @@ async def create(

if project:
from planecli.utils.resolve import resolve_project_async

proj = await resolve_project_async(project, client, workspace)
project_id = proj["id"]
item = await resolve_work_item_async(issue, client, workspace, project_id)
Expand All @@ -169,13 +252,17 @@ async def create(

item_id = item["id"]

comment_data = CreateWorkItemComment(comment_html=f"<p>{body}</p>")
comment_data = CreateWorkItemComment(comment_html=_body_to_html(body))
comment = await run_sdk(
client.work_items.comments.create,
workspace, project_id, item_id, comment_data,
workspace,
project_id,
item_id,
comment_data,
)
data = _enrich_comment(comment.model_dump())
from planecli.cache import invalidate_resource

await invalidate_resource("comments", workspace, project_id, item_id)
except PlaneError as e:
raise handle_api_error(e)
Expand All @@ -184,6 +271,7 @@ async def create(
output_single(data, [], as_json=True)
else:
from planecli.formatters import console

console.print(f"[green]Comment added to {issue}.[/]")


Expand Down Expand Up @@ -217,6 +305,7 @@ async def update(

if project:
from planecli.utils.resolve import resolve_project_async

proj = await resolve_project_async(project, client, workspace)
project_id = proj["id"]
item = await resolve_work_item_async(issue, client, workspace, project_id)
Expand All @@ -227,13 +316,18 @@ async def update(

item_id = item["id"]

update_data = UpdateWorkItemComment(comment_html=f"<p>{body}</p>")
update_data = UpdateWorkItemComment(comment_html=_body_to_html(body))
comment = await run_sdk(
client.work_items.comments.update,
workspace, project_id, item_id, comment_id, update_data,
workspace,
project_id,
item_id,
comment_id,
update_data,
)
data = _enrich_comment(comment.model_dump())
from planecli.cache import invalidate_resource

await invalidate_resource("comments", workspace, project_id, item_id)
except PlaneError as e:
raise handle_api_error(e)
Expand All @@ -242,6 +336,7 @@ async def update(
output_single(data, [], as_json=True)
else:
from planecli.formatters import console

console.print(f"[green]Comment updated on {issue}.[/]")


Expand Down Expand Up @@ -271,6 +366,7 @@ async def delete(

if project:
from planecli.utils.resolve import resolve_project_async

proj = await resolve_project_async(project, client, workspace)
project_id = proj["id"]
item = await resolve_work_item_async(issue, client, workspace, project_id)
Expand All @@ -282,9 +378,13 @@ async def delete(
item_id = item["id"]
await run_sdk(
client.work_items.comments.delete,
workspace, project_id, item_id, comment_id,
workspace,
project_id,
item_id,
comment_id,
)
from planecli.cache import invalidate_resource

await invalidate_resource("comments", workspace, project_id, item_id)
except PlaneError as e:
raise handle_api_error(e)
Expand Down
116 changes: 98 additions & 18 deletions tests/test_commands/test_comments.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,82 @@
import pytest
from plane.errors import PlaneError

from planecli.commands.comments import _enrich_comment
from planecli.commands.comments import _body_to_html, _enrich_comment


def test_body_to_html_wraps_single_line_in_one_paragraph():
assert _body_to_html("hello") == "<p>hello</p>"


def test_body_to_html_splits_blank_lines_into_paragraphs():
body = "first\n\nsecond\n\n\nthird"
assert _body_to_html(body) == "<p>first</p><p>second</p><p>third</p>"


def test_body_to_html_converts_single_newline_to_br():
body = "追加修复:\n1) first\n2) second"
assert _body_to_html(body) == "<p>追加修复:<br/>1) first<br/>2) second</p>"


def test_body_to_html_linkifies_bare_url():
body = "PR https://github.com/owner/repo/pull/12"
assert (
_body_to_html(body) == '<p>PR <a href="https://github.com/owner/repo/pull/12">'
"https://github.com/owner/repo/pull/12</a></p>"
)


def test_body_to_html_keeps_trailing_punctuation_out_of_link():
body = "见 https://example.com/a。下一条"
assert (
_body_to_html(body)
== '<p>见 <a href="https://example.com/a">https://example.com/a</a>。下一条</p>'
)


def test_body_to_html_does_not_double_link_existing_anchor():
body = '<a href="https://example.com">https://example.com</a>'
assert _body_to_html(body) == f"<p>{body}</p>"


def test_body_to_html_linkifies_multiple_urls_in_one_line():
body = "Issue https://a/1\nPR https://b/2"
assert (
_body_to_html(body) == '<p>Issue <a href="https://a/1">https://a/1</a><br/>'
'PR <a href="https://b/2">https://b/2</a></p>'
)


def test_body_to_html_converts_inline_backticks_to_code():
body = "分支 `feat/x`,已推送"
assert _body_to_html(body) == "<p>分支 <code>feat/x</code>,已推送</p>"


def test_body_to_html_escapes_html_inside_inline_code():
body = "存 `<p>` 标签"
assert _body_to_html(body) == "<p>存 <code>&lt;p&gt;</code> 标签</p>"


def test_body_to_html_does_not_linkify_url_inside_code():
body = "`https://a/1` 是内部地址"
assert _body_to_html(body) == "<p><code>https://a/1</code> 是内部地址</p>"


def test_body_to_html_converts_fenced_block_to_pre():
body = "前\n\n```\ncode <b>x</b>\n```\n\n后"
assert (
_body_to_html(body) == "<p>前</p><pre><code>code &lt;b&gt;x&lt;/b&gt;</code></pre><p>后</p>"
)


def test_body_to_html_fenced_block_ignores_language_hint():
body = "```python\nprint(1)\n```"
assert _body_to_html(body) == "<pre><code>print(1)</code></pre>"


def test_enrich_comment_resolves_actor_name_from_members_map():
members_map = {"user-1": "Alice"}
result = _enrich_comment(
{"actor": "user-1", "comment_html": "<p>hello</p>"}, members_map
)
result = _enrich_comment({"actor": "user-1", "comment_html": "<p>hello</p>"}, members_map)
assert result["actor_name"] == "Alice"
assert result["body_text"] == "hello"

Expand All @@ -25,9 +93,7 @@ def test_enrich_comment_falls_back_to_uuid_without_map():


def test_enrich_comment_falls_back_to_uuid_when_member_missing():
result = _enrich_comment(
{"actor": "user-x", "comment_html": ""}, {"user-1": "Alice"}
)
result = _enrich_comment({"actor": "user-x", "comment_html": ""}, {"user-1": "Alice"})
assert result["actor_name"] == "user-x"
assert result["body_text"] == ""

Expand All @@ -43,10 +109,18 @@ async def test_fetch_issue_comments_sorts_and_resolves(mock_comments, mock_membe
]
# Returned out of order; helper must sort oldest -> newest
mock_comments.return_value = [
{"id": "c2", "actor": "u2", "comment_html": "<p>later</p>",
"created_at": "2026-02-11T10:00:00Z"},
{"id": "c1", "actor": "u1", "comment_html": "<p>earlier</p>",
"created_at": "2026-02-10T10:00:00Z"},
{
"id": "c2",
"actor": "u2",
"comment_html": "<p>later</p>",
"created_at": "2026-02-11T10:00:00Z",
},
{
"id": "c1",
"actor": "u1",
"comment_html": "<p>earlier</p>",
"created_at": "2026-02-10T10:00:00Z",
},
]

result = await fetch_issue_comments("ws", "p1", "item-1")
Expand All @@ -64,8 +138,12 @@ async def test_fetch_issue_comments_returns_all(mock_comments, mock_members):

mock_members.return_value = []
mock_comments.return_value = [
{"id": f"c{i}", "actor": "u1", "comment_html": "<p>x</p>",
"created_at": f"2026-02-{i:02d}T00:00:00Z"}
{
"id": f"c{i}",
"actor": "u1",
"comment_html": "<p>x</p>",
"created_at": f"2026-02-{i:02d}T00:00:00Z",
}
for i in range(1, 31)
]

Expand All @@ -87,18 +165,20 @@ async def test_fetch_issue_comments_raises_on_failure(mock_comments, mock_member

@patch("planecli.cache.cached_list_members", new_callable=AsyncMock)
@patch("planecli.cache.cached_list_comments", new_callable=AsyncMock)
async def test_fetch_issue_comments_degrades_when_members_fail(
mock_comments, mock_members
):
async def test_fetch_issue_comments_degrades_when_members_fail(mock_comments, mock_members):
"""A members-list failure is a secondary-enrichment concern: it must not
take down comments that already loaded successfully (names fall back to
the raw actor UUID, same as the no-map case)."""
from planecli.commands.comments import fetch_issue_comments

mock_members.side_effect = PlaneError("members unavailable")
mock_comments.return_value = [
{"id": "c1", "actor": "u1", "comment_html": "<p>hi</p>",
"created_at": "2026-02-10T10:00:00Z"},
{
"id": "c1",
"actor": "u1",
"comment_html": "<p>hi</p>",
"created_at": "2026-02-10T10:00:00Z",
},
]

result = await fetch_issue_comments("ws", "p1", "item-1")
Expand Down