From b2efad0e093b9a26b39838260b656f4836acf4ea Mon Sep 17 00:00:00 2001 From: josiglo <53374513+josiglo@users.noreply.github.com> Date: Tue, 29 Sep 2026 15:46:27 +0200 Subject: [PATCH] feat(pr): add --pending to save draft review comments Bitbucket Cloud lets a comment be created with "pending": true. It then stays a draft that only its author sees until they press Finish review on the pull request. bj pr comment --pending sets that field for top-level, inline and reply comments. The API has no call to publish drafts, so that step stays in the web UI. If the response comes back without pending, bj warns that the comment is already public. bj pr view --comments now tags pending comments. --- bitbucket_jira_cli/api/bitbucket.py | 5 ++ bitbucket_jira_cli/commands/pr.py | 33 +++++++- bitbucket_jira_cli/render.py | 4 +- docs/reference/pr/comment.md | 14 ++++ scripts/gen_cli_docs.py | 13 ++++ skills/bitbucket-jira-cli/SKILL.md | 1 + tests/test_pr_comment.py | 112 ++++++++++++++++++++++++++++ 7 files changed, 179 insertions(+), 3 deletions(-) create mode 100644 tests/test_pr_comment.py 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]