diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b3c96d..8619a72 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +### Add +- `bb pr show` command for viewing PR comments with inline code comment support + Usage: bb pr show [limit] [unresolved] + --- ## [1.0.2] - 2024-02-21 diff --git a/bin/bb b/bin/bb index 2ff3d44..5823ab5 100755 --- a/bin/bb +++ b/bin/bb @@ -33,6 +33,7 @@ foreach (scandir($actionsFolder) as $file) { $actions = [ 'pr' => \BBCli\BBCli\Actions\Pr::class, + 'pr-details' => \BBCli\BBCli\Actions\PrDetails::class, 'pipeline' => \BBCli\BBCli\Actions\Pipeline::class, 'branch' => \BBCli\BBCli\Actions\Branch::class, 'auth' => \BBCli\BBCli\Actions\Auth::class, @@ -125,7 +126,8 @@ if (in_array($userAction, $staticCommands['autocomplete'])) { // Check if git folder exists (skip if --project is used) if ($actionClass::CHECK_GIT_FOLDER && !$projectUrl) { - if (!is_dir(getcwd().'/.git')) { + $gitDir = trim((string) exec('git rev-parse --git-dir 2>/dev/null')); + if ($gitDir === '') { o('ERROR: No git repository found in current directory.', 'red'); o('Use --project "owner/repo" to work with remote repository.', 'yellow'); exit(1); diff --git a/src/Actions/Pr.php b/src/Actions/Pr.php index 4757034..404a1a0 100644 --- a/src/Actions/Pr.php +++ b/src/Actions/Pr.php @@ -32,6 +32,7 @@ class Pr extends Base 'decline' => 'decline', 'merge' => 'merge, m', 'create' => 'create', + 'show' => 'show', ]; /** @@ -331,4 +332,18 @@ private function currentUserUuid() return array_get($response, 'uuid'); } + + /** + * List pull request general and inline comments. + * + * Delegates to PrDetails action class to keep Pr focused on lifecycle operations. + * + * @param int $prId + * @param bool $unresolved + * @return void + */ + public function show($prId = null, $unresolved = false) + { + (new PrDetails())->show($prId, $unresolved); + } } diff --git a/src/Actions/PrDetails.php b/src/Actions/PrDetails.php new file mode 100644 index 0000000..99e0596 --- /dev/null +++ b/src/Actions/PrDetails.php @@ -0,0 +1,229 @@ + 'show', + ]; + + /** + * List pull request general and inline comments. + * + * Fetches all comments and displays them chronologically. + * + * @param int $prId + * @param bool $unresolved + * @return void + */ + public function show($prId = null, $unresolved = false) + { + if (is_null($prId)) { + throw new \Exception('PR ID required. Usage: bb pr show [unresolved]'); + } + + if (is_string($unresolved)) { + $unresolved = filter_var($unresolved, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); + } + + if (!is_bool($unresolved)) { + throw new \Exception('Invalid unresolved value. Usage: bb pr show [unresolved]'); + } + + $this->displayComments($prId, $unresolved); + } + + /** + * Display pull request comments. + * + * @param int $prId + * @param bool $unresolved + * @return void + */ + private function displayComments($prId, $unresolved) + { + $commentsData = $this->fetchAllComments($prId, $unresolved); + $comments = $commentsData['general']; + $inlineComments = $commentsData['inline']; + + o("## Pull Request Comments (PR #{$prId})", 'green'); + o(''); + + // General comments section + o("### General Comments", 'yellow'); + if (empty($comments)) { + o('No general comments found.', 'cyan'); + } else { + foreach ($comments as $comment) { + $formatted = $this->formatGeneralComment($comment); + o("{$formatted['author']} ({$formatted['timestamp']}):", 'green'); + o($formatted['content']); + o(''); + } + } + + // Inline comments section + o("### Inline Code Comments", 'yellow'); + if (empty($inlineComments)) { + o('No inline comments found.', 'cyan'); + } else { + foreach ($inlineComments as $comment) { + $formatted = $this->formatInlineComment($comment); + o("File: {$formatted['file']}:{$formatted['line']}", 'cyan'); + o("{$formatted['author']} ({$formatted['timestamp']}):", 'green'); + o($formatted['content']); + o(''); + } + } + } + + /** + * Fetch all comments for a pull request. + * + * Fetches all pages of comments and sorts them chronologically. + * Applies unresolved filter only to inline comments (general comments are never resolvable). + * + * @param int $prId + * @param bool $unresolved + * @return array + */ + private function fetchAllComments($prId, $unresolved = false) + { + $general = []; + $inline = []; + $page = 1; + $pagelen = 100; // API max for efficiency + + // Fetch all pages until no more + while ($page <= 100) { // Safety limit: max 100 pages = 10,000 comments + $response = $this->makeRequest( + 'GET', + "/pullrequests/{$prId}/comments?pagelen={$pagelen}&page={$page}" + ); + + foreach ($response['values'] ?? [] as $comment) { + // Partition by inline path presence + if (empty(array_get($comment, 'inline.path'))) { + $general[] = $comment; + } else { + $inline[] = $comment; + } + } + + // Stop if no more pages + if (empty($response['next'])) { + break; + } + + $page++; + } + + // Sort both arrays chronologically by created_on + usort($general, function ($a, $b) { + return strcmp($a['created_on'], $b['created_on']); + }); + usort($inline, function ($a, $b) { + return strcmp($a['created_on'], $b['created_on']); + }); + + // Apply unresolved filter if requested + // Per Bitbucket API: only inline comments are resolvable + // General comments (no inline field) are never resolvable and always shown + if ($unresolved) { + $inline = array_values(array_filter($inline, [$this, 'isUnresolvedComment'])); + // General comments remain unchanged - they are never resolvable + } + + return [ + 'general' => $general, + 'inline' => $inline, + ]; + } + + /** + * Check if a comment is unresolved. + * + * @param array $comment + * @return bool + */ + private function isUnresolvedComment($comment) + { + // Per Bitbucket API: if 'resolution' key exists (even as empty object {}), + // the comment is resolved. No resolution key means unresolved. + return !array_key_exists('resolution', $comment); + } + + /** + * Format a general comment for display. + * + * @param array $comment + * @return array + */ + private function formatGeneralComment($comment) + { + if (array_get($comment, 'deleted')) { + return [ + 'author' => array_get($comment, 'user.display_name', 'Unknown'), + 'timestamp' => format_relative_timestamp(array_get($comment, 'created_on')), + 'content' => '[DELETED]', + 'uuid' => array_get($comment, 'user.uuid'), + ]; + } + + return [ + 'author' => array_get($comment, 'user.display_name'), + 'timestamp' => format_relative_timestamp(array_get($comment, 'created_on')), + 'content' => array_get($comment, 'content.raw', ''), + 'uuid' => array_get($comment, 'user.uuid'), + ]; + } + + /** + * Format an inline comment for display. + * + * @param array $comment + * @return array + */ + private function formatInlineComment($comment) + { + $line = array_get($comment, 'inline.to'); + if (is_null($line)) { + $line = array_get($comment, 'inline.from'); + } + + // Handle deleted inline comments + if (array_get($comment, 'deleted')) { + return [ + 'author' => array_get($comment, 'user.display_name', 'Unknown'), + 'timestamp' => format_relative_timestamp(array_get($comment, 'created_on')), + 'content' => '[DELETED]', + 'file' => array_get($comment, 'inline.path', ''), + 'line' => $line, + ]; + } + + return [ + 'author' => array_get($comment, 'user.display_name'), + 'timestamp' => format_relative_timestamp(array_get($comment, 'created_on')), + 'content' => array_get($comment, 'content.raw', ''), + 'file' => array_get($comment, 'inline.path', ''), + 'line' => $line, + ]; + } +} diff --git a/src/utils/helpers.php b/src/utils/helpers.php index 2b43611..0d0c161 100644 --- a/src/utils/helpers.php +++ b/src/utils/helpers.php @@ -146,3 +146,38 @@ function userConfig($key, $default = null) return array_get($config, $key, $default); } } + +if (!function_exists('format_relative_timestamp')) { + /** + * Format a timestamp as relative time. + * + * Returns human-readable relative time for past dates (e.g., "2 hours ago"). + * Returns absolute date format for future dates (e.g., "Jan 15, 2024"). + * + * @param string $dateString ISO 8601 datetime string + * @return string Formatted timestamp + */ + function format_relative_timestamp($dateString) + { + $date = new \DateTime($dateString); + $now = new \DateTime(); + $diff = $now->diff($date); + + // Handle future timestamps - return absolute date + if ($diff->invert === 0) { + return $date->format('M d, Y'); + } + + // Past timestamps - use relative time + // Use total days ($diff->days), not day component ($diff->d) + if ($diff->days > 7) { + return $date->format('M d, Y'); + } elseif ($diff->days > 0) { + return $diff->days . ' day' . ($diff->days > 1 ? 's' : '') . ' ago'; + } elseif ($diff->h > 0) { + return $diff->h . ' hour' . ($diff->h > 1 ? 's' : '') . ' ago'; + } else { + return $diff->i . ' minute' . ($diff->i > 1 ? 's' : '') . ' ago'; + } + } +}