From d0ed5563a592e0c48faa28edb65b72208ec905e4 Mon Sep 17 00:00:00 2001 From: William Thorsen Date: Wed, 11 Mar 2026 23:27:09 -0700 Subject: [PATCH 1/3] agents: feat| Add bb-pr-inline-comment skill for inline PR comments Create a new skill with a self-contained shell script that posts inline comments on Bitbucket pull requests anchored to specific file paths and line numbers via the REST API. The script auto-detects workspace/repo from `git remote get-url origin`(HTTPS and SSH) and PR ID from the current branch via the Bitbucket API. Auth resolves in priority order: bot credentials (Basic), env var token (Bearer), macOS keychain (Bearer). Includes `SKILL.md` documenting usage, arguments, auto-detection behavior, auth setup, and examples for single and batch invocation. --- .../skills/bb-pr-inline-comment/SKILL.md | 104 ++++++ .../bb-pr-inline-comment.sh | 308 ++++++++++++++++++ 2 files changed, 412 insertions(+) create mode 100644 packages/agents/content/skills/bb-pr-inline-comment/SKILL.md create mode 100755 packages/agents/content/skills/bb-pr-inline-comment/bb-pr-inline-comment.sh diff --git a/packages/agents/content/skills/bb-pr-inline-comment/SKILL.md b/packages/agents/content/skills/bb-pr-inline-comment/SKILL.md new file mode 100644 index 00000000..020981d7 --- /dev/null +++ b/packages/agents/content/skills/bb-pr-inline-comment/SKILL.md @@ -0,0 +1,104 @@ +--- +name: bb-pr-inline-comment +description: Post inline comments on Bitbucket pull requests anchored to specific file paths and line numbers +user-invocable: true +--- + +# Post inline PR comment on Bitbucket + +Post a comment anchored to a specific file path and line number on a Bitbucket pull request using the REST API. + +## When to use + +- During code review, to leave line-level feedback on a pull request +- When the Bitbucket MCP server's `comment` action is insufficient because it only supports general PR comments, not inline ones +- To post batch review comments programmatically across multiple files + +## Arguments + +| Flag | Description | Required | Default | +| ---- | --------------------------- | -------- | --------------------------------- | +| `-f` | File path in the repository | Yes | — | +| `-l` | Line number | Yes | — | +| `-m` | Comment text | No | Reads from stdin | +| `-w` | Bitbucket workspace | No | Auto-detected from `git remote` | +| `-r` | Repository slug | No | Auto-detected from `git remote` | +| `-p` | Pull request ID | No | Auto-detected from current branch | + +## Auto-detection + +When `-w`, `-r`, or `-p` are omitted, the script auto-detects values: + +- **Workspace and repo** are parsed from `git remote get-url origin`. Both HTTPS (`https://bitbucket.org/ws/repo`) and SSH (`git@bitbucket.org:ws/repo.git`) URLs are supported. +- **PR ID** is resolved by querying the Bitbucket API for open pull requests whose source branch matches the current git branch. If no open PR is found, or if multiple are found, the script exits with a descriptive error. + +## Auth setup + +The script resolves authentication in priority order: + +1. **Bot credentials (Basic auth):** Set `BITBUCKET_BOT_USERNAME` and `BITBUCKET_BOT_TOKEN` environment variables. +2. **API token (Bearer auth):** Set the `BITBUCKET_API_TOKEN` environment variable. +3. **macOS keychain (Bearer auth):** Add a keychain entry (macOS only): + +```bash +security add-generic-password -a "$USER" -s "bitbucket-api-token" -w "" +``` + +Generate a token at https://bitbucket.org/account/settings/app-passwords/ with scopes: Repositories (Read), Pull requests (Read + Write). + +## Invocation + +Run the companion script directly via `bash`: + +```bash +bash "$(dirname "$SKILL_PATH")/bb-pr-inline-comment.sh" -f -l -m +``` + +Or, if the skill directory is known: + +```bash +bash packages/agents/content/skills/bb-pr-inline-comment/bb-pr-inline-comment.sh \ + -f src/foo.ts -l 42 -m "Consider extracting this into a helper function." +``` + +## Examples + +### Single comment with all values auto-detected + +```bash +bash bb-pr-inline-comment.sh -f src/utils.ts -l 15 -m "This null check is redundant." +``` + +### Explicit workspace, repo, and PR ID + +```bash +bash bb-pr-inline-comment.sh \ + -w myteam -r my-repo -p 123 \ + -f src/handler.ts -l 88 \ + -m "Consider using early return here." +``` + +### Pipe comment from stdin + +```bash +echo "This function has O(n²) complexity — consider a map lookup." \ + | bash bb-pr-inline-comment.sh -f src/search.ts -l 34 +``` + +### Batch comments in a loop + +```bash +while IFS=$'\t' read -r file line comment; do + bash bb-pr-inline-comment.sh -f "$file" -l "$line" -m "$comment" +done <<'EOF' +src/api.ts 12 Missing error handling for network failures. +src/api.ts 45 This timeout value should be configurable. +src/utils.ts 8 Unused import. +EOF +``` + +## Dependencies + +- `curl` — HTTP requests +- `jq` — JSON construction and parsing +- `git` — workspace/repo/branch detection diff --git a/packages/agents/content/skills/bb-pr-inline-comment/bb-pr-inline-comment.sh b/packages/agents/content/skills/bb-pr-inline-comment/bb-pr-inline-comment.sh new file mode 100755 index 00000000..023db68c --- /dev/null +++ b/packages/agents/content/skills/bb-pr-inline-comment/bb-pr-inline-comment.sh @@ -0,0 +1,308 @@ +#!/usr/bin/env bash +set -euo pipefail + +# bb-pr-inline-comment.sh — Post an inline comment on a Bitbucket pull request. +# +# Authenticates via (in priority order): +# 1. Bot credentials: BITBUCKET_BOT_USERNAME + BITBUCKET_BOT_TOKEN (Basic auth) +# 2. BITBUCKET_API_TOKEN env var (Bearer auth) +# 3. macOS keychain entry "bitbucket-api-token" (Bearer auth, macOS only) +# +# Posts a comment anchored to a specific file and line number. +# +# Usage: +# bb-pr-inline-comment.sh -f -l -m +# echo "body" | bb-pr-inline-comment.sh -f -l +# bb-pr-inline-comment.sh --help + +readonly PROG="$(basename "$0")" + +main() { + # Show help (manual check — getopts cannot parse long options) + if [[ "${1:-}" == "--help" ]]; then + show_usage 0 + fi + + # Check dependencies + for cmd in curl jq git; do + if ! command -v "$cmd" &>/dev/null; then + echo "$PROG: required command '$cmd' not found" >&2 + exit 127 + fi + done + + local workspace="" repo_slug="" pr_id="" file_path="" line="" comment="" + + # Parse options + while getopts ":w:r:p:f:l:m:h" opt; do + case $opt in + w) workspace="$OPTARG" ;; + r) repo_slug="$OPTARG" ;; + p) pr_id="$OPTARG" ;; + f) file_path="$OPTARG" ;; + l) line="$OPTARG" ;; + m) comment="$OPTARG" ;; + h) show_usage 0 ;; + :) + echo "$PROG: option -$OPTARG requires an argument" >&2 + exit 1 + ;; + *) + echo "$PROG: unknown option -$OPTARG" >&2 + show_usage + ;; + esac + done + + # Read comment from stdin if not provided via -m + if [[ -z "$comment" ]]; then + if [[ -t 0 ]]; then + echo "$PROG: no comment provided; use -m or pipe to stdin" >&2 + exit 1 + fi + comment="$(cat)" + fi + + # Auto-detect workspace and repo from git remote + if [[ -z "$workspace" || -z "$repo_slug" ]]; then + detect_workspace_repo + workspace="${workspace:-$_detected_workspace}" + repo_slug="${repo_slug:-$_detected_repo}" + fi + + # Resolve auth + local auth_style="" + local auth_token="" + resolve_auth + + # Auto-detect PR ID from current branch + if [[ -z "$pr_id" ]]; then + detect_pr_id "$workspace" "$repo_slug" + pr_id="$_detected_pr_id" + fi + + # Validate required arguments + assert_nonempty "workspace (-w)" "$workspace" + assert_nonempty "repo (-r)" "$repo_slug" + assert_nonempty "pr_id (-p)" "$pr_id" + assert_nonempty "file (-f)" "$file_path" + assert_nonempty "line (-l)" "$line" + assert_nonempty "comment (-m or stdin)" "$comment" + + # Build payload + local payload + payload="$( + jq -n \ + --arg comment "$comment" \ + --arg path "$file_path" \ + --argjson line "$line" \ + '{ + content: { raw: $comment }, + inline: { path: $path, to: $line } + }' + )" + + # Post comment + local url="https://api.bitbucket.org/2.0/repositories/${workspace}/${repo_slug}/pullrequests/${pr_id}/comments" + local response http_status + + if [[ "$auth_style" == "basic" ]]; then + response=$(curl --silent --show-error --write-out "\n%{http_code}" \ + --user "${BITBUCKET_BOT_USERNAME}:${BITBUCKET_BOT_TOKEN}" \ + --header "Content-Type: application/json" \ + --request POST "$url" \ + --data "$payload") + else + response=$(curl --silent --show-error --write-out "\n%{http_code}" \ + --header "Authorization: Bearer ${auth_token}" \ + --header "Content-Type: application/json" \ + --request POST "$url" \ + --data "$payload") + fi + + http_status=$(echo "$response" | tail -n1) + local body + body=$(echo "$response" | sed '$d') + + if [[ "$http_status" -lt 200 || "$http_status" -ge 300 ]]; then + echo "$PROG: API request failed with HTTP $http_status" >&2 + echo "$body" >&2 + exit 1 + fi + + echo "$body" +} + +# Validate that a variable is non-empty +assert_nonempty() { + local name="$1" value="$2" + if [[ -z "$value" ]]; then + echo "$PROG: $name is empty or unset" >&2 + exit 1 + fi +} + +# Detect workspace and repo slug from git remote origin URL. +# Handles HTTPS (https://bitbucket.org/ws/repo[.git]) and +# SSH (git@bitbucket.org:ws/repo.git) patterns. +# Sets _detected_workspace and _detected_repo. +detect_workspace_repo() { + local remote_url + remote_url="$(git remote get-url origin 2>/dev/null || true)" + + if [[ -z "$remote_url" ]]; then + echo "$PROG: cannot auto-detect workspace/repo — no git remote 'origin' found" >&2 + exit 1 + fi + + local ws="" repo="" + + # Strip trailing .git suffix before matching + remote_url="${remote_url%.git}" + + if [[ "$remote_url" =~ bitbucket\.org[:/]([^/]+)/([^/]+)$ ]]; then + ws="${BASH_REMATCH[1]}" + repo="${BASH_REMATCH[2]}" + else + echo "$PROG: cannot parse workspace/repo from remote URL: $remote_url" >&2 + exit 1 + fi + + _detected_workspace="$ws" + _detected_repo="$repo" +} + +# Resolve authentication credentials. +# Sets auth_style ("basic" or "bearer") and auth_token in the caller's scope. +resolve_auth() { + # Priority 1: bot credentials (Basic auth) + if [[ -n "${BITBUCKET_BOT_USERNAME:-}" && -n "${BITBUCKET_BOT_TOKEN:-}" ]]; then + auth_style="basic" + return + fi + + # Priority 2: BITBUCKET_API_TOKEN env var (Bearer auth) + if [[ -n "${BITBUCKET_API_TOKEN:-}" ]]; then + auth_style="bearer" + auth_token="$BITBUCKET_API_TOKEN" + return + fi + + # Priority 3: macOS keychain (Bearer auth) — only on macOS + if [[ "$(uname -s)" == "Darwin" ]]; then + local keychain_token + keychain_token="$(security find-generic-password -s "bitbucket-api-token" -w 2>/dev/null || true)" + if [[ -n "$keychain_token" ]]; then + auth_style="bearer" + auth_token="$keychain_token" + return + fi + fi + + echo "$PROG: no authentication configured" >&2 + echo " Set BITBUCKET_BOT_USERNAME + BITBUCKET_BOT_TOKEN, or" >&2 + echo " Set BITBUCKET_API_TOKEN, or" >&2 + echo " Add macOS keychain entry 'bitbucket-api-token'" >&2 + exit 1 +} + +# Detect PR ID from the current git branch by querying the Bitbucket API. +# Sets _detected_pr_id. +detect_pr_id() { + local ws="$1" repo="$2" + local branch + branch="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || true)" + + if [[ -z "$branch" || "$branch" == "HEAD" ]]; then + echo "$PROG: cannot auto-detect PR ID — not on a named branch" >&2 + exit 1 + fi + + local encoded_query + encoded_query=$(printf 'source.branch.name="%s"' "$branch" | jq --slurp --raw-input --raw-output '@uri') + + local url="https://api.bitbucket.org/2.0/repositories/${ws}/${repo}/pullrequests?q=${encoded_query}&state=OPEN" + local response http_status + + if [[ "$auth_style" == "basic" ]]; then + response=$(curl --silent --show-error --write-out "\n%{http_code}" \ + --user "${BITBUCKET_BOT_USERNAME}:${BITBUCKET_BOT_TOKEN}" \ + "$url") + else + response=$(curl --silent --show-error --write-out "\n%{http_code}" \ + --header "Authorization: Bearer ${auth_token}" \ + "$url") + fi + + http_status=$(echo "$response" | tail -n1) + local body + body=$(echo "$response" | sed '$d') + + if [[ "$http_status" -lt 200 || "$http_status" -ge 300 ]]; then + echo "$PROG: failed to query PRs for branch '$branch' (HTTP $http_status)" >&2 + echo "$body" >&2 + exit 1 + fi + + local count + count=$(echo "$body" | jq '.size') + + if [[ "$count" -eq 0 ]]; then + echo "$PROG: no open pull request found for branch '$branch'" >&2 + exit 1 + fi + + if [[ "$count" -gt 1 ]]; then + echo "$PROG: multiple open pull requests found for branch '$branch':" >&2 + echo "$body" | jq -r '.values[] | " PR #\(.id): \(.title)"' >&2 + exit 1 + fi + + _detected_pr_id=$(echo "$body" | jq '.values[0].id') +} + +# Display usage information and exit +show_usage() { + cat >&2 < -l -m + $PROG -w -r -p -f -l -m + echo "body" | $PROG -f -l + $PROG --help + +Options: + -w Bitbucket workspace (auto-detected from git remote) + -r Repository slug (auto-detected from git remote) + -p Pull request ID (auto-detected from current branch) + -f File path in the repo (required) + -l Line number (required) + -m Comment text (reads from stdin if omitted) + -h, --help Show this help + +Auto-detection: + Workspace and repo are parsed from \`git remote get-url origin\`. + PR ID is resolved by querying the Bitbucket API for open PRs matching + the current branch name. + +Auth (in priority order): + 1. BITBUCKET_BOT_USERNAME + BITBUCKET_BOT_TOKEN (Basic auth) + 2. BITBUCKET_API_TOKEN env var (Bearer auth) + 3. macOS keychain "bitbucket-api-token" (Bearer auth, macOS only) + +Keychain setup (macOS): + security add-generic-password -a "\$USER" -s "bitbucket-api-token" -w "" + + Generate a token at: https://bitbucket.org/account/settings/app-passwords/ + Required scopes: Repositories (Read), Pull requests (Read + Write) + +Examples: + $PROG -f src/foo.ts -l 42 -m "Refactor this loop." + $PROG -w myteam -r my-repo -p 123 -f src/foo.ts -l 42 -m "Fix this." + echo "Long comment" | $PROG -f src/bar.py -l 10 +USAGE + exit "${1:-1}" +} + +main "$@" From 6c770d22a0804c8b3e7e08d1731abd112f7476ef Mon Sep 17 00:00:00 2001 From: William Thorsen Date: Thu, 12 Mar 2026 16:23:23 -0700 Subject: [PATCH 2/3] agents|refactor: Refine bb-pr-inline-comment script --- .../bb-pr-inline-comment.sh | 115 +++++++++--------- 1 file changed, 58 insertions(+), 57 deletions(-) diff --git a/packages/agents/content/skills/bb-pr-inline-comment/bb-pr-inline-comment.sh b/packages/agents/content/skills/bb-pr-inline-comment/bb-pr-inline-comment.sh index 023db68c..27577856 100755 --- a/packages/agents/content/skills/bb-pr-inline-comment/bb-pr-inline-comment.sh +++ b/packages/agents/content/skills/bb-pr-inline-comment/bb-pr-inline-comment.sh @@ -36,21 +36,21 @@ main() { # Parse options while getopts ":w:r:p:f:l:m:h" opt; do case $opt in - w) workspace="$OPTARG" ;; - r) repo_slug="$OPTARG" ;; - p) pr_id="$OPTARG" ;; - f) file_path="$OPTARG" ;; - l) line="$OPTARG" ;; - m) comment="$OPTARG" ;; - h) show_usage 0 ;; - :) - echo "$PROG: option -$OPTARG requires an argument" >&2 - exit 1 - ;; - *) - echo "$PROG: unknown option -$OPTARG" >&2 - show_usage - ;; + w) workspace="$OPTARG" ;; + r) repo_slug="$OPTARG" ;; + p) pr_id="$OPTARG" ;; + f) file_path="$OPTARG" ;; + l) line="$OPTARG" ;; + m) comment="$OPTARG" ;; + h) show_usage 0 ;; + :) + echo "$PROG: option -$OPTARG requires an argument" >&2 + exit 1 + ;; + *) + echo "$PROG: unknown option -$OPTARG" >&2 + show_usage + ;; esac done @@ -89,6 +89,12 @@ main() { assert_nonempty "line (-l)" "$line" assert_nonempty "comment (-m or stdin)" "$comment" + # Validate line is a positive integer + if [[ ! "$line" =~ ^[0-9]+$ ]]; then + echo "$PROG: line number must be a positive integer, got '$line'" >&2 + exit 1 + fi + # Build payload local payload payload="$( @@ -104,42 +110,55 @@ main() { # Post comment local url="https://api.bitbucket.org/2.0/repositories/${workspace}/${repo_slug}/pullrequests/${pr_id}/comments" + + local body + body="$(bb_api POST "$url" "$payload")" + echo "$body" +} + +# Validate that a variable is non-empty +assert_nonempty() { + local name="$1" value="$2" + if [[ -z "$value" ]]; then + echo "$PROG: $name is empty or unset" >&2 + exit 1 + fi +} + +# Make an authenticated request to the Bitbucket API. +# Usage: bb_api [body] +# Prints the response body on success; exits on HTTP error. +bb_api() { + local method="$1" url="$2" body="${3:-}" local response http_status + local -a curl_args=( + --silent --show-error --write-out "\n%{http_code}" + --request "$method" + ) + if [[ "$auth_style" == "basic" ]]; then - response=$(curl --silent --show-error --write-out "\n%{http_code}" \ - --user "${BITBUCKET_BOT_USERNAME}:${BITBUCKET_BOT_TOKEN}" \ - --header "Content-Type: application/json" \ - --request POST "$url" \ - --data "$payload") + curl_args+=(--user "${BITBUCKET_BOT_USERNAME}:${BITBUCKET_BOT_TOKEN}") else - response=$(curl --silent --show-error --write-out "\n%{http_code}" \ - --header "Authorization: Bearer ${auth_token}" \ - --header "Content-Type: application/json" \ - --request POST "$url" \ - --data "$payload") + curl_args+=(--header "Authorization: Bearer ${auth_token}") + fi + + if [[ -n "$body" ]]; then + curl_args+=(--header "Content-Type: application/json" --data "$body") fi + response=$(curl "${curl_args[@]}" "$url") http_status=$(echo "$response" | tail -n1) - local body - body=$(echo "$response" | sed '$d') + local response_body + response_body=$(echo "$response" | sed '$d') if [[ "$http_status" -lt 200 || "$http_status" -ge 300 ]]; then echo "$PROG: API request failed with HTTP $http_status" >&2 - echo "$body" >&2 + echo "$response_body" >&2 exit 1 fi - echo "$body" -} - -# Validate that a variable is non-empty -assert_nonempty() { - local name="$1" value="$2" - if [[ -z "$value" ]]; then - echo "$PROG: $name is empty or unset" >&2 - exit 1 - fi + echo "$response_body" } # Detect workspace and repo slug from git remote origin URL. @@ -222,27 +241,9 @@ detect_pr_id() { encoded_query=$(printf 'source.branch.name="%s"' "$branch" | jq --slurp --raw-input --raw-output '@uri') local url="https://api.bitbucket.org/2.0/repositories/${ws}/${repo}/pullrequests?q=${encoded_query}&state=OPEN" - local response http_status - - if [[ "$auth_style" == "basic" ]]; then - response=$(curl --silent --show-error --write-out "\n%{http_code}" \ - --user "${BITBUCKET_BOT_USERNAME}:${BITBUCKET_BOT_TOKEN}" \ - "$url") - else - response=$(curl --silent --show-error --write-out "\n%{http_code}" \ - --header "Authorization: Bearer ${auth_token}" \ - "$url") - fi - http_status=$(echo "$response" | tail -n1) local body - body=$(echo "$response" | sed '$d') - - if [[ "$http_status" -lt 200 || "$http_status" -ge 300 ]]; then - echo "$PROG: failed to query PRs for branch '$branch' (HTTP $http_status)" >&2 - echo "$body" >&2 - exit 1 - fi + body="$(bb_api GET "$url")" local count count=$(echo "$body" | jq '.size') From b39138cdd02dc9230266a9e16beaac7b58cb603d Mon Sep 17 00:00:00 2001 From: William Thorsen Date: Thu, 12 Mar 2026 16:40:25 -0700 Subject: [PATCH 3/3] agents|fix: Fix option parsing order and line validation in bb-pr-inline-comment Move dependency checks after `getopts` so `-h`/`--help` works even if `curl`, `jq`, and `git` are not installed. Add `shift` after getopts loop. Tighten line-number regex to reject zero. --- .../bb-pr-inline-comment.sh | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/packages/agents/content/skills/bb-pr-inline-comment/bb-pr-inline-comment.sh b/packages/agents/content/skills/bb-pr-inline-comment/bb-pr-inline-comment.sh index 27577856..880cea93 100755 --- a/packages/agents/content/skills/bb-pr-inline-comment/bb-pr-inline-comment.sh +++ b/packages/agents/content/skills/bb-pr-inline-comment/bb-pr-inline-comment.sh @@ -23,14 +23,6 @@ main() { show_usage 0 fi - # Check dependencies - for cmd in curl jq git; do - if ! command -v "$cmd" &>/dev/null; then - echo "$PROG: required command '$cmd' not found" >&2 - exit 127 - fi - done - local workspace="" repo_slug="" pr_id="" file_path="" line="" comment="" # Parse options @@ -53,6 +45,15 @@ main() { ;; esac done + shift $((OPTIND - 1)) + + # Check dependencies + for cmd in curl jq git; do + if ! command -v "$cmd" &>/dev/null; then + echo "$PROG: required command '$cmd' not found" >&2 + exit 127 + fi + done # Read comment from stdin if not provided via -m if [[ -z "$comment" ]]; then @@ -90,7 +91,7 @@ main() { assert_nonempty "comment (-m or stdin)" "$comment" # Validate line is a positive integer - if [[ ! "$line" =~ ^[0-9]+$ ]]; then + if [[ ! "$line" =~ ^[1-9][0-9]*$ ]]; then echo "$PROG: line number must be a positive integer, got '$line'" >&2 exit 1 fi