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
5 changes: 5 additions & 0 deletions bitbucket_jira_cli/api/bitbucket.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Expand Down
33 changes: 31 additions & 2 deletions bitbucket_jira_cli/commands/pr.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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."""
Expand All @@ -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)
Expand All @@ -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()))

Expand Down
4 changes: 3 additions & 1 deletion bitbucket_jira_cli/render.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
14 changes: 14 additions & 0 deletions docs/reference/pr/comment.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -32,8 +36,18 @@ bj pr comment [OPTIONS] [PR_ID]
| `--delete <integer>` | Delete a comment id. |
| `--resolve <integer>` | Resolve a thread. |
| `--unresolve <integer>` | Unresolve a thread. |
| `--pending` | Save as a pending review comment, visible only to you until you finish the review in Bitbucket. |
| `-R, --repo <text>` | 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)
13 changes: 13 additions & 0 deletions scripts/gen_cli_docs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand Down Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions skills/bitbucket-jira-cli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
112 changes: 112 additions & 0 deletions tests/test_pr_comment.py
Original file line number Diff line number Diff line change
@@ -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]
Loading