diff --git a/src/main/core/pull-requests/controller.ts b/src/main/core/pull-requests/controller.ts index e5b7fd42a..232b06030 100644 --- a/src/main/core/pull-requests/controller.ts +++ b/src/main/core/pull-requests/controller.ts @@ -1,6 +1,11 @@ import { RequestError } from '@octokit/request-error'; import { createRPCController } from '@shared/ipc/rpc'; -import type { ListPrOptions, PullRequestError, PullRequestFile } from '@shared/pull-requests'; +import type { + ListPrOptions, + PullRequestComment, + PullRequestError, + PullRequestFile, +} from '@shared/pull-requests'; import { err, ok } from '@shared/result'; import { log } from '@main/lib/logger'; import { telemetryService } from '@main/lib/telemetry'; @@ -240,4 +245,21 @@ export const pullRequestController = createRPCController({ }); } }, + + getPullRequestComments: async (repositoryUrl: string, prNumber: number) => { + try { + const result = await prSyncEngine.getPullRequestComments(repositoryUrl, prNumber); + if (!result.success) { + return err({ type: 'invalid_repository', input: result.error.input }); + } + const comments: PullRequestComment[] = result.data; + return ok({ comments }); + } catch (error) { + log.error('Failed to get pull request comments:', error); + return err({ + type: 'comments_failed', + message: error instanceof Error ? error.message : 'Unable to get pull request comments', + }); + } + }, }); diff --git a/src/main/core/pull-requests/pr-sync-engine.ts b/src/main/core/pull-requests/pr-sync-engine.ts index 05d0b1aec..7ae196e2c 100644 --- a/src/main/core/pull-requests/pr-sync-engine.ts +++ b/src/main/core/pull-requests/pr-sync-engine.ts @@ -12,8 +12,10 @@ import type { MergeStateStatus, PrSyncProgress, PullRequest, + PullRequestComment, PullRequestFile, PullRequestStatus, + PullRequestUser, } from '@shared/pull-requests'; import { ok, type Result } from '@shared/result'; import { getOctokit } from '@main/core/github/services/octokit-provider'; @@ -80,6 +82,23 @@ function actorUserId(actor: GqlUser): string { return actor.databaseId != null ? String(actor.databaseId) : `login:${actor.login}`; } +function restUserToPullRequestUser(user: { + id: number; + login: string; + avatar_url: string; + html_url: string; +}): PullRequestUser { + return { + userId: String(user.id), + userName: user.login, + displayName: user.login, + avatarUrl: user.avatar_url || null, + url: user.html_url, + userCreatedAt: null, + userUpdatedAt: null, + }; +} + interface GqlPrNode { number: number; title: string; @@ -1067,6 +1086,98 @@ export class PrSyncEngine { return ok(); } + async getPullRequestComments( + repositoryUrl: string, + prNumber: number + ): Promise> { + const repository = parseGitHubRepositoryResult(repositoryUrl); + if (!repository.success) return repository; + const { owner, repo } = repository.data; + const octokit = await this.getOctokit(); + const pullRequestUrl = `${repository.data.repositoryUrl}/pull/${prNumber}`; + + const [issueComments, reviewComments, reviews] = await Promise.all([ + withRetry(() => + githubRateLimiter.acquire().then(() => + octokit.paginate(octokit.rest.issues.listComments, { + owner, + repo, + issue_number: prNumber, + per_page: 100, + }) + ) + ), + withRetry(() => + githubRateLimiter.acquire().then(() => + octokit.paginate(octokit.rest.pulls.listReviewComments, { + owner, + repo, + pull_number: prNumber, + per_page: 100, + }) + ) + ), + withRetry(() => + githubRateLimiter.acquire().then(() => + octokit.paginate(octokit.rest.pulls.listReviews, { + owner, + repo, + pull_number: prNumber, + per_page: 100, + }) + ) + ), + ]); + + return ok([ + ...issueComments.map((comment) => ({ + id: `issue-comment:${comment.id}`, + pullRequestUrl, + kind: 'issue' as const, + body: comment.body ?? '', + url: comment.html_url, + author: comment.user ? restUserToPullRequestUser(comment.user) : null, + path: null, + line: null, + isResolved: false, + isOutdated: false, + createdAt: comment.created_at, + updatedAt: comment.updated_at, + })), + ...reviews.flatMap((review) => { + if (!review.body?.trim() || !review.submitted_at) return []; + return { + id: `review:${review.id}`, + pullRequestUrl, + kind: 'review' as const, + body: review.body, + url: review.html_url, + author: review.user ? restUserToPullRequestUser(review.user) : null, + path: null, + line: null, + isResolved: false, + isOutdated: false, + createdAt: review.submitted_at, + updatedAt: review.submitted_at, + }; + }), + ...reviewComments.map((comment) => ({ + id: `review-comment:${comment.id}`, + pullRequestUrl, + kind: 'review' as const, + body: comment.body ?? '', + url: comment.html_url, + author: comment.user ? restUserToPullRequestUser(comment.user) : null, + path: comment.path ?? null, + line: comment.line ?? comment.original_line ?? null, + isResolved: false, + isOutdated: comment.position == null, + createdAt: comment.created_at, + updatedAt: comment.updated_at, + })), + ]); + } + async getPullRequestFiles( repositoryUrl: string, prNumber: number diff --git a/src/renderer/features/tasks/diff-view/changes-panel/components/pr-entry/checks-list.tsx b/src/renderer/features/tasks/diff-view/changes-panel/components/pr-entry/checks-list.tsx index 8ce6a6170..ec049c72a 100644 --- a/src/renderer/features/tasks/diff-view/changes-panel/components/pr-entry/checks-list.tsx +++ b/src/renderer/features/tasks/diff-view/changes-panel/components/pr-entry/checks-list.tsx @@ -2,7 +2,7 @@ import { CheckCircle2, ExternalLink, Loader2, MinusCircle, XCircle } from 'lucid import { observer } from 'mobx-react-lite'; import { useMemo } from 'react'; import type { PullRequest } from '@shared/pull-requests'; -import { useCheckRuns } from '@renderer/features/tasks/diff-view/state/use-check-runs'; +import { useSyncCheckRuns } from '@renderer/features/tasks/diff-view/state/use-check-runs'; import { rpc } from '@renderer/lib/ipc'; import { EmptyState } from '@renderer/lib/ui/empty-state'; import { @@ -11,6 +11,8 @@ import { type CheckRun, type CheckRunBucket, } from '@renderer/utils/github'; +import { CommentsList } from './comments-list'; +import { usePullRequestComments } from './use-pull-request-comments'; const bucketOrder: Record = { fail: 0, @@ -41,11 +43,9 @@ export function CheckRunItem({ check }: { check: CheckRun }) { check.completedAt ?? undefined ); const subtitle = check.appName ?? check.workflowName; + const detailsUrl = check.detailsUrl; return ( - )} - + ); } @@ -86,11 +91,11 @@ export function ChecksList({ checks }: { checks: CheckRun[] }) { ); if (sorted.length === 0) { - return ; + return
No checks available
; } return ( -
+
{sorted.map((check, i) => ( ))} @@ -99,6 +104,32 @@ export function ChecksList({ checks }: { checks: CheckRun[] }) { } export const PrChecksList = observer(function PrChecksList({ pr }: { pr: PullRequest }) { - const { checks } = useCheckRuns(pr); - return ; + const { checks } = useSyncCheckRuns(pr); + const commentsQuery = usePullRequestComments(pr); + const comments = commentsQuery.data ?? []; + + if (checks.length === 0 && comments.length === 0 && !commentsQuery.isLoading) { + return ; + } + + return ( +
+
+
+ Checks +
+ +
+
+
+ Comments +
+ +
+
+ ); }); diff --git a/src/renderer/features/tasks/diff-view/changes-panel/components/pr-entry/comments-list.tsx b/src/renderer/features/tasks/diff-view/changes-panel/components/pr-entry/comments-list.tsx new file mode 100644 index 000000000..4f4114ebe --- /dev/null +++ b/src/renderer/features/tasks/diff-view/changes-panel/components/pr-entry/comments-list.tsx @@ -0,0 +1,121 @@ +import { ExternalLink, MessageSquare } from 'lucide-react'; +import { useMemo } from 'react'; +import type { PullRequestComment } from '@shared/pull-requests'; +import { rpc } from '@renderer/lib/ipc'; +import { MarkdownRenderer } from '@renderer/lib/ui/markdown-renderer'; +import { RelativeTime } from '@renderer/lib/ui/relative-time'; +import { cn } from '@renderer/utils/utils'; + +function commentAuthorLabel(comment: PullRequestComment): string { + return comment.author?.displayName ?? comment.author?.userName ?? 'Unknown author'; +} + +function commentLocationLabel(comment: PullRequestComment): string | null { + if (!comment.path) return null; + return comment.line ? `${comment.path}:${comment.line}` : comment.path; +} + +function isBotAuthor(comment: PullRequestComment): boolean { + return comment.author?.userName.endsWith('[bot]') ?? false; +} + +function CommentItem({ comment }: { comment: PullRequestComment }) { + const location = commentLocationLabel(comment); + const author = commentAuthorLabel(comment); + const avatarRadiusClass = isBotAuthor(comment) ? 'rounded' : 'rounded-full'; + + return ( +
+ {comment.author?.avatarUrl ? ( + {author} + ) : ( +
+ +
+ )} +
+
+ {author} + / + + {comment.isResolved && ( + <> + / + Resolved + + )} +
+ {location && ( +
+ {location} +
+ )} +
+ +
+
+ +
+ ); +} + +export function CommentsList({ + comments, + isLoading, + error, +}: { + comments: PullRequestComment[]; + isLoading?: boolean; + error?: Error | null; +}) { + const sorted = useMemo( + () => + [...comments].sort( + (a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime() + ), + [comments] + ); + + if (isLoading) { + return
Loading comments...
; + } + + if (error) { + return
Unable to load comments
; + } + + if (sorted.length === 0) { + return
No comments available
; + } + + return ( +
+ {sorted.map((comment) => ( + + ))} +
+ ); +} diff --git a/src/renderer/features/tasks/diff-view/changes-panel/components/pr-entry/use-pull-request-comments.ts b/src/renderer/features/tasks/diff-view/changes-panel/components/pr-entry/use-pull-request-comments.ts new file mode 100644 index 000000000..7bdcf17fd --- /dev/null +++ b/src/renderer/features/tasks/diff-view/changes-panel/components/pr-entry/use-pull-request-comments.ts @@ -0,0 +1,22 @@ +import { useQuery } from '@tanstack/react-query'; +import { getPrNumber, pullRequestErrorMessage, type PullRequest } from '@shared/pull-requests'; +import { rpc } from '@renderer/lib/ipc'; + +export function usePullRequestComments(pr: PullRequest) { + const prNumber = getPrNumber(pr); + + return useQuery({ + queryKey: ['pull-request-comments', pr.repositoryUrl, prNumber], + queryFn: async () => { + if (prNumber === null) return []; + + const response = await rpc.pullRequests.getPullRequestComments(pr.repositoryUrl, prNumber); + if (!response.success) { + throw new Error(pullRequestErrorMessage(response.error)); + } + return response.data.comments; + }, + enabled: prNumber !== null, + staleTime: 30_000, + }); +} diff --git a/src/renderer/features/tasks/diff-view/state/use-check-runs.ts b/src/renderer/features/tasks/diff-view/state/use-check-runs.ts index 78e489520..cb2e9d6f1 100644 --- a/src/renderer/features/tasks/diff-view/state/use-check-runs.ts +++ b/src/renderer/features/tasks/diff-view/state/use-check-runs.ts @@ -3,7 +3,7 @@ import type { PullRequest } from '@shared/pull-requests'; import { rpc } from '@renderer/lib/ipc'; import { computeCheckRunsSummary, type CheckRun } from '@renderer/utils/github'; -export function useCheckRuns(pr: PullRequest) { +export function useSyncCheckRuns(pr: PullRequest) { const checks = useMemo(() => pr.checks as CheckRun[], [pr.checks]); const summary = useMemo(() => computeCheckRunsSummary(checks), [checks]); diff --git a/src/renderer/lib/ui/markdown-renderer.tsx b/src/renderer/lib/ui/markdown-renderer.tsx index 5595a1dc8..f8e6facb6 100644 --- a/src/renderer/lib/ui/markdown-renderer.tsx +++ b/src/renderer/lib/ui/markdown-renderer.tsx @@ -19,6 +19,7 @@ interface MarkdownRendererProps { content: string; variant?: Variant; className?: string; + allowHtml?: boolean; /** * Optional callback for resolving non-external image src values (e.g. relative * paths inside a workspace). Should return a `data:` URI string, or `null` to @@ -294,6 +295,7 @@ export const MarkdownRenderer: React.FC = ({ content, variant = 'full', className, + allowHtml = variant === 'full', resolveImage, }) => { const { effectiveTheme } = useTheme(); @@ -303,7 +305,7 @@ export const MarkdownRenderer: React.FC = ({ const compactComponents = useCompactComponents(); const components = variant === 'full' ? fullComponents : compactComponents; - const rehypePlugins = variant === 'full' ? FULL_REHYPE_PLUGINS : COMPACT_REHYPE_PLUGINS; + const rehypePlugins = allowHtml ? FULL_REHYPE_PLUGINS : COMPACT_REHYPE_PLUGINS; const normalizedContent = useMemo(() => normalizeLatexDelimiters(content), [content]); return ( diff --git a/src/shared/pull-requests.ts b/src/shared/pull-requests.ts index 9809f3b1a..a21653eb7 100644 --- a/src/shared/pull-requests.ts +++ b/src/shared/pull-requests.ts @@ -41,6 +41,23 @@ export type PullRequestCheck = { appLogoUrl: string | null; }; +export type PullRequestCommentKind = 'issue' | 'review'; + +export type PullRequestComment = { + id: string; + pullRequestUrl: string; + kind: PullRequestCommentKind; + body: string; + url: string; + author: PullRequestUser | null; + path: string | null; + line: number | null; + isResolved: boolean; + isOutdated: boolean; + createdAt: string; + updatedAt: string; +}; + /** Fully denormalised PR view used throughout the renderer. */ export type PullRequest = { url: string; @@ -119,6 +136,7 @@ export type PullRequestError = | { type: 'sync_failed'; message: string } | { type: 'refresh_failed'; message: string } | { type: 'checks_failed'; message: string } + | { type: 'comments_failed'; message: string } | { type: 'create_failed'; message: string } | { type: 'merge_failed'; message: string } | { type: 'mark_ready_failed'; message: string }