diff --git a/bitbucket_jira_cli/api/bitbucket.py b/bitbucket_jira_cli/api/bitbucket.py index abd2bdf..0da3bcb 100644 --- a/bitbucket_jira_cli/api/bitbucket.py +++ b/bitbucket_jira_cli/api/bitbucket.py @@ -140,12 +140,17 @@ async def add_pr_comment( *, inline: dict[str, Any] | None = None, parent_id: int | None = None, + pending: bool = False, ) -> dict[str, Any]: body: dict[str, Any] = {"content": {"raw": text}} if inline: body["inline"] = inline if parent_id is not None: body["parent"] = {"id": parent_id} + if pending: + # A pending comment is a draft that only its author sees until they + # press "Finish review" in the Bitbucket web UI. + body["pending"] = True response = await self.request( "POST", f"{self._pr_base(workspace, repo_slug)}/{pr_id}/comments", json=body ) diff --git a/bitbucket_jira_cli/commands/pr.py b/bitbucket_jira_cli/commands/pr.py index c216292..ea6eeb8 100644 --- a/bitbucket_jira_cli/commands/pr.py +++ b/bitbucket_jira_cli/commands/pr.py @@ -630,6 +630,22 @@ def _one_action(*flags: object) -> bool: return sum(1 for f in flags if f) <= 1 +def _added_comment_message( + created: dict[str, Any], what: str, pr_id: int, *, pending: bool +) -> str: + if pending and created.get("pending"): + return ( + f"Added pending {what} {created.get('id')} on PR #{pr_id}. " + "Finish the review in Bitbucket to publish it." + ) + if pending: + err_console.print( + "[yellow]![/yellow] Bitbucket did not keep the comment pending, " + "so it is already visible to everyone." + ) + return f"Added {what} on PR #{pr_id}" + + @pr_app.command() def comment( # noqa: PLR0913 — many gh + Bitbucket comment modes on one verb. pr_id: Annotated[int | None, typer.Argument(help="PR id (default: current branch).")] = None, @@ -647,6 +663,14 @@ def comment( # noqa: PLR0913 — many gh + Bitbucket comment modes on one verb. unresolve_id: Annotated[ int | None, typer.Option("--unresolve", help="Unresolve a thread.") ] = None, + pending: Annotated[ + bool, + typer.Option( + "--pending", + help="Save as a pending review comment, visible only to you until you " + "finish the review in Bitbucket.", + ), + ] = False, repo: RepoOpt = None, ) -> None: """Comment on a PR: top-level, inline (--file/--line), reply (--reply-to), or manage.""" @@ -655,6 +679,9 @@ def comment( # noqa: PLR0913 — many gh + Bitbucket comment modes on one verb. if not _one_action(delete_id, resolve_id, unresolve_id, edit_id): msg = "Pass at most one of --edit/--delete/--resolve/--unresolve." raise BjError(msg) + if pending and not _one_action(pending, delete_id, resolve_id, unresolve_id, edit_id): + msg = "--pending only applies to new comments, not --edit/--delete/--resolve/--unresolve." + raise BjError(msg) if (file is None) != (line is None): msg = "Inline comments need both --file and --line." raise BjError(msg) @@ -681,9 +708,11 @@ async def _run() -> str: if edit_id is not None: await client.update_pr_comment(ws, slug, resolved, edit_id, text) return f"Edited comment {edit_id} on PR #{resolved}" - await client.add_pr_comment(ws, slug, resolved, text, inline=inline, parent_id=reply_to) + created = await client.add_pr_comment( + ws, slug, resolved, text, inline=inline, parent_id=reply_to, pending=pending + ) what = "inline comment" if inline else "reply" if reply_to else "comment" - return f"Added {what} on PR #{resolved}" + return _added_comment_message(created, what, resolved, pending=pending) success(run_with_status("Working…", _run())) diff --git a/bitbucket_jira_cli/render.py b/bitbucket_jira_cli/render.py index 87834ba..224547e 100644 --- a/bitbucket_jira_cli/render.py +++ b/bitbucket_jira_cli/render.py @@ -95,9 +95,11 @@ def render_one(comment: dict[str, Any], depth: int) -> None: # The list endpoint returns resolution={} (empty, falsy) when resolved and # null when not — so test for presence, not truthiness. resolved = " [green](resolved)[/green]" if comment.get("resolution") is not None else "" + pending = " [yellow](pending)[/yellow]" if comment.get("pending") else "" body = comment.get("content", {}).get("raw", "") indent = " " * depth - console.print(f"{indent}[cyan]{who}[/cyan] [dim]#{cid}[/dim]{loc}{resolved}: {body}") + tags = f"{loc}{resolved}{pending}" + console.print(f"{indent}[cyan]{who}[/cyan] [dim]#{cid}[/dim]{tags}: {body}") for child in children.get(cid, []): render_one(child, depth + 1) diff --git a/docs/reference/pr/comment.md b/docs/reference/pr/comment.md index 43681b8..9741df2 100644 --- a/docs/reference/pr/comment.md +++ b/docs/reference/pr/comment.md @@ -12,6 +12,10 @@ Comment on a PR: top-level, inline (--file/--line), reply (--reply-to), or manag bj pr comment [OPTIONS] [PR_ID] ``` +## Description + +Comment on a pull request. With `--pending` the comment is saved as a draft that only you can see. It is published together with your other pending comments when you press *Finish review* on the pull request in Bitbucket, since the Bitbucket Cloud API has no call that publishes them. + ## Arguments | Argument | Description | @@ -32,8 +36,18 @@ bj pr comment [OPTIONS] [PR_ID] | `--delete ` | Delete a comment id. | | `--resolve ` | Resolve a thread. | | `--unresolve ` | Unresolve a thread. | +| `--pending` | Save as a pending review comment, visible only to you until you finish the review in Bitbucket. | | `-R, --repo ` | Target repo as WORKSPACE/REPO. | +## Examples + +```bash +bj pr comment 42 --body 'Looks good' +bj pr comment 42 --file src/app.py --line 12 --body 'Typo here' +# Draft review comments, published later from the web UI +bj pr comment 42 --file src/app.py --line 12 --body 'Nit' --pending +``` + ## See also - [`bj pr`](index.md) diff --git a/scripts/gen_cli_docs.py b/scripts/gen_cli_docs.py index 2509bf4..cd0c511 100644 --- a/scripts/gen_cli_docs.py +++ b/scripts/gen_cli_docs.py @@ -75,6 +75,13 @@ "Merge a pull request. If the PR's branch carries a Jira key, the linked " "ticket is transitioned to the configured done state on success." ), + "pr comment": ( + "Comment on a pull request. With `--pending` the comment is saved as a " + "draft that only you can see. It is published together with your other " + "pending comments when you press *Finish review* on the pull request in " + "Bitbucket, since the Bitbucket Cloud API has no call that publishes " + "them." + ), "issue view": ( "View a Jira issue. With no key, the key is read from the current git " "branch name." @@ -200,6 +207,12 @@ "bj pr view 42 --comments", "bj pr view --web", ], + "pr comment": [ + "bj pr comment 42 --body 'Looks good'", + "bj pr comment 42 --file src/app.py --line 12 --body 'Typo here'", + "# Draft review comments, published later from the web UI", + "bj pr comment 42 --file src/app.py --line 12 --body 'Nit' --pending", + ], "pr merge": [ "bj pr merge --squash --delete-branch", "bj pr merge 42 --dry-run", diff --git a/skills/bitbucket-jira-cli/SKILL.md b/skills/bitbucket-jira-cli/SKILL.md index 20ccb68..899da59 100644 --- a/skills/bitbucket-jira-cli/SKILL.md +++ b/skills/bitbucket-jira-cli/SKILL.md @@ -141,6 +141,7 @@ bj pr view --json | jq '.title' bj pr diff bj pr review --approve # or --request-changes --body "..." bj pr comment --body "..." # add --file/--line for inline +bj pr comment --body "..." --pending # draft, published via "Finish review" in the web UI bj pr merge --squash --delete-branch --yes # Issues (Jira) diff --git a/tests/test_pr_comment.py b/tests/test_pr_comment.py new file mode 100644 index 0000000..5bcc06a --- /dev/null +++ b/tests/test_pr_comment.py @@ -0,0 +1,112 @@ +"""Tests for `bj pr comment`, focused on pending (draft) review comments.""" + +from pathlib import Path +from typing import Any + +import pytest +from rich.text import Text +from typer.testing import CliRunner + +from bitbucket_jira_cli.commands import pr as pr_cmd +from bitbucket_jira_cli.main import app +from bitbucket_jira_cli.render import _render_comment_threads +from bitbucket_jira_cli.ui import console + +runner = CliRunner() + + +class FakeBitbucket: + """Stand-in for BitbucketClient recording the comments it is asked to add.""" + + def __init__(self) -> None: + self.added: list[dict[str, Any]] = [] + self.keep_pending = True + + async def __aenter__(self) -> "FakeBitbucket": + return self + + async def __aexit__(self, *_exc: object) -> bool: + return False + + async def add_pr_comment( + self, + _workspace: str, + _repo_slug: str, + pr_id: int, + text: str, + *, + inline: dict[str, Any] | None = None, + parent_id: int | None = None, + pending: bool = False, + ) -> dict[str, Any]: + call = {"pr_id": pr_id, "text": text, "inline": inline, "parent_id": parent_id} + self.added.append({**call, "pending": pending}) + return {"id": 7, "pending": pending and self.keep_pending} + + +@pytest.fixture +def fake_bb(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> FakeBitbucket: + """Point the pr commands at a fake Bitbucket and an empty config dir.""" + monkeypatch.setenv("BJ_CONFIG_DIR", str(tmp_path)) + client = FakeBitbucket() + monkeypatch.setattr(pr_cmd, "_bb", lambda _config: client) + return client + + +def _comment(*args: str) -> Any: + return runner.invoke(app, ["pr", "comment", "42", "--repo", "ws/repo", *args]) + + +def test_comment_is_published_by_default(fake_bb: FakeBitbucket) -> None: + """Without --pending the comment is posted as a normal, visible comment.""" + result = _comment("--body", "hi") + assert result.exit_code == 0 + assert fake_bb.added == [ + {"pr_id": 42, "text": "hi", "inline": None, "parent_id": None, "pending": False} + ] + + +def test_pending_inline_comment(fake_bb: FakeBitbucket) -> None: + """--pending is passed through for an inline comment and reported as pending.""" + result = _comment("--body", "nit", "--file", "src/app.py", "--line", "3", "--pending") + assert result.exit_code == 0 + assert fake_bb.added[0]["pending"] is True + assert fake_bb.added[0]["inline"] == {"path": "src/app.py", "to": 3} + assert "pending inline comment 7" in Text.from_ansi(result.output).plain + + +def test_pending_reply(fake_bb: FakeBitbucket) -> None: + """--pending also works for a reply to an existing comment.""" + result = _comment("--body", "agreed", "--reply-to", "5", "--pending") + assert result.exit_code == 0 + assert fake_bb.added[0]["parent_id"] == 5 + assert fake_bb.added[0]["pending"] is True + + +def test_pending_warns_when_bitbucket_publishes_anyway(fake_bb: FakeBitbucket) -> None: + """If the response is not pending, the user is told the comment is already public.""" + fake_bb.keep_pending = False + result = _comment("--body", "hi", "--pending") + assert result.exit_code == 0 + assert "did not keep the comment pending" in Text.from_ansi(result.output).plain + + +@pytest.mark.parametrize("flag", ["--edit", "--delete", "--resolve", "--unresolve"]) +def test_pending_rejected_with_management_flags(fake_bb: FakeBitbucket, flag: str) -> None: + """--pending makes no sense when editing or managing an existing comment.""" + result = _comment(flag, "5", "--body", "x", "--pending") + assert result.exit_code == 1 + assert not fake_bb.added + + +def test_threads_mark_pending_comments() -> None: + """Pending comments are tagged in the rendered thread view.""" + comments = [ + {"id": 1, "user": {"display_name": "Ann"}, "content": {"raw": "draft"}, "pending": True}, + {"id": 2, "user": {"display_name": "Bob"}, "content": {"raw": "live"}, "pending": False}, + ] + with console.capture() as capture: + _render_comment_threads(comments) + lines = Text.from_ansi(capture.get()).plain.splitlines() + assert "(pending)" in lines[0] + assert "(pending)" not in lines[1]