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
24 changes: 23 additions & 1 deletion src/main/core/pull-requests/controller.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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<PullRequestError>({ 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<PullRequestError>({
type: 'comments_failed',
message: error instanceof Error ? error.message : 'Unable to get pull request comments',
});
}
},
});
111 changes: 111 additions & 0 deletions src/main/core/pull-requests/pr-sync-engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -1067,6 +1086,98 @@ export class PrSyncEngine {
return ok();
}

async getPullRequestComments(
repositoryUrl: string,
prNumber: number
): Promise<Result<PullRequestComment[], GitHubRepositoryParseError>> {
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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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<CheckRunBucket, number> = {
fail: 0,
Expand Down Expand Up @@ -41,11 +43,9 @@ export function CheckRunItem({ check }: { check: CheckRun }) {
check.completedAt ?? undefined
);
const subtitle = check.appName ?? check.workflowName;
const detailsUrl = check.detailsUrl;
return (
<button
className="group relative flex items-center gap-2 px-3 py-2 hover:bg-background-1 rounded-md"
onClick={() => rpc.app.openExternal(check.detailsUrl!)}
>
<div className="group relative flex items-center gap-2 px-3 py-2 hover:bg-background-1 rounded-md">
<div className="min-w-0 flex-1 flex flex-col gap-1">
<div className="flex items-center gap-2">
<BucketIcon bucket={bucket} />
Expand All @@ -66,13 +66,18 @@ export function CheckRunItem({ check }: { check: CheckRun }) {
</div>
<div className="flex shrink-0 items-center gap-2">
{duration && <span className="text-xs text-foreground-passive">{duration}</span>}
{check.detailsUrl && (
<div className="absolute right-3 top-1/2 -translate-y-1/2 hidden group-hover:flex items-center justify-center bg-background-1 text-foreground-muted hover:text-foreground rounded px-1 py-0.5">
{detailsUrl && (
<button
type="button"
aria-label={`Open ${check.name} check details`}
className="absolute right-3 top-1/2 -translate-y-1/2 hidden group-hover:flex items-center justify-center bg-background-1 text-foreground-muted hover:text-foreground rounded px-1 py-0.5"
onClick={() => void rpc.app.openExternal(detailsUrl)}
>
<ExternalLink className="size-3.5" />
</div>
</button>
)}
</div>
</button>
</div>
);
}

Expand All @@ -86,11 +91,11 @@ export function ChecksList({ checks }: { checks: CheckRun[] }) {
);

if (sorted.length === 0) {
return <EmptyState label="No checks" description="No checks available" />;
return <div className="px-3 py-2 text-xs text-foreground-passive">No checks available</div>;
}

return (
<div className="flex flex-col gap-[1px] py-2">
<div className="flex flex-col gap-[1px]">
{sorted.map((check, i) => (
<CheckRunItem key={`${check.name}-${i}`} check={check} />
))}
Expand All @@ -99,6 +104,32 @@ export function ChecksList({ checks }: { checks: CheckRun[] }) {
}

export const PrChecksList = observer(function PrChecksList({ pr }: { pr: PullRequest }) {
const { checks } = useCheckRuns(pr);
return <ChecksList checks={checks} />;
const { checks } = useSyncCheckRuns(pr);
const commentsQuery = usePullRequestComments(pr);
const comments = commentsQuery.data ?? [];

if (checks.length === 0 && comments.length === 0 && !commentsQuery.isLoading) {
return <EmptyState label="No checks or comments" description="Nothing available yet" />;
}
Comment thread
jschwxrz marked this conversation as resolved.

return (
<div className="flex flex-col gap-4 py-2">
<section>
<div className="px-3 pb-1 text-[11px] font-medium uppercase text-foreground-passive">
Checks
</div>
<ChecksList checks={checks} />
</section>
<section>
<div className="px-3 pb-1 text-[11px] font-medium uppercase text-foreground-passive">
Comments
</div>
<CommentsList
comments={comments}
isLoading={commentsQuery.isLoading}
error={commentsQuery.error}
/>
</section>
</div>
);
});
Original file line number Diff line number Diff line change
@@ -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 (
<div className="group relative flex w-full min-w-0 gap-2 rounded-md px-3 py-2 text-left hover:bg-background-1">
{comment.author?.avatarUrl ? (
<img
src={comment.author.avatarUrl}
alt={author}
className={cn('mt-0.5 size-5 shrink-0', avatarRadiusClass)}
/>
) : (
<div
className={cn(
'mt-0.5 flex size-5 shrink-0 items-center justify-center bg-background-2 text-foreground-muted',
avatarRadiusClass
)}
>
<MessageSquare className="size-3" />
</div>
)}
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-1.5 text-xs text-foreground-muted">
<span className="truncate font-medium text-foreground">{author}</span>
<span className="shrink-0 text-foreground-passive">/</span>
<RelativeTime compact value={comment.updatedAt} className="shrink-0" />
{comment.isResolved && (
<>
<span className="shrink-0 text-foreground-passive">/</span>
<span className="shrink-0 text-foreground-passive">Resolved</span>
</>
)}
</div>
{location && (
<div className="mt-0.5 truncate font-mono text-[11px] text-foreground-passive">
{location}
</div>
)}
<div
className={cn(
'mt-1 break-words text-xs leading-relaxed text-foreground-muted [&_*:last-child]:mb-0 [&_p]:mb-1.5',
comment.isOutdated && 'text-foreground-passive'
)}
>
<MarkdownRenderer
content={comment.body}
variant="compact"
allowHtml={isBotAuthor(comment)}
/>
</div>
</div>
<button
className="absolute right-3 top-2 hidden items-center justify-center rounded bg-background-1 px-1 py-0.5 text-foreground-muted hover:text-foreground group-hover:flex"
onClick={() => void rpc.app.openExternal(comment.url)}
>
<ExternalLink className="size-3.5" />
</button>
</div>
);
}

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()
),
Comment thread
jschwxrz marked this conversation as resolved.
[comments]
);

if (isLoading) {
return <div className="px-3 py-2 text-xs text-foreground-passive">Loading comments...</div>;
}

if (error) {
return <div className="px-3 py-2 text-xs text-foreground-passive">Unable to load comments</div>;
}

if (sorted.length === 0) {
return <div className="px-3 py-2 text-xs text-foreground-passive">No comments available</div>;
}

return (
<div className="flex flex-col gap-[1px]">
{sorted.map((comment) => (
<CommentItem key={comment.id} comment={comment} />
))}
</div>
);
}
Loading
Loading