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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <pr_id> [limit] [unresolved]

---

## [1.0.2] - 2024-02-21
Expand Down
4 changes: 3 additions & 1 deletion bin/bb
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down
15 changes: 15 additions & 0 deletions src/Actions/Pr.php
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ class Pr extends Base
'decline' => 'decline',
'merge' => 'merge, m',
'create' => 'create',
'show' => 'show',
];

/**
Expand Down Expand Up @@ -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);
}
}
229 changes: 229 additions & 0 deletions src/Actions/PrDetails.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
<?php

namespace BBCli\BBCli\Actions;

use BBCli\BBCli\Base;

/**
* Pull Request Details - View PR comments and related information
*
* @see https://bb-cli.github.io/docs/commands/pull-request
*/
class PrDetails extends Base
{
/**
* Pull request details default command.
*/
const DEFAULT_METHOD = 'show';

/**
* Pull request details commands.
*/
const AVAILABLE_COMMANDS = [
'show' => '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 <pr_id> [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 <pr_id> [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,
];
}
}
35 changes: 35 additions & 0 deletions src/utils/helpers.php
Original file line number Diff line number Diff line change
Expand Up @@ -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';
}
}
}