feat(dashboard): richer GitHub import comments — avatar, time, human/bot badge, nav, filter

Comment shape gains authorAvatarUrl + authorIsBot (gh GraphQL Bot/__typename or REST user.type==Bot or [bot] login; avatar from API or github.com/{login}.png, suppressed for bots). New shared CommentsThread (PR + issue) shows author avatar (lucide Bot/User fallback), readable localized timestamp (ISO title), a Human/Bot badge + data-comment-author-type, prev/next chevron nav that scrolls+highlights, and an All/Human/Bot filter defaulting to All.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-06-22 11:18:12 -07:00
parent 459237c974
commit 35b1838c5f
5 changed files with 604 additions and 95 deletions

View File

@@ -2433,8 +2433,20 @@ FNXC:GitHubImport 2026-06-23-01:00:
Per-PR detail for the Import Tasks PR preview pane. `gh pr list` (apiFetchGitHubPulls) returns only comment COUNT + no per-check status, so the preview fetches the FULL comment thread + per-check status ON SELECTION via this client fn (never for the whole list — too expensive).
`status` is the gh CheckRun status (queued/in_progress/completed) or StatusContext state; `conclusion` (success/failure/neutral/...) is present once a check completes.
*/
/*
FNXC:GitHubImport 2026-06-23-03:30:
Comment shape carries `authorAvatarUrl?` (optional, backward-compatible) and `authorIsBot` so the preview renders an avatar + human/bot badge per comment. `authorIsBot` is derived server-side (author type is a GitHub Bot OR login ends in `[bot]`); `authorAvatarUrl` is omitted for bots whose synthetic login does not resolve to a real avatar.
*/
export interface GitHubCommentDetail {
author: string;
body: string;
createdAt: string;
authorAvatarUrl?: string;
authorIsBot: boolean;
}
export interface GitHubPullDetail {
comments: Array<{ author: string; body: string; createdAt: string }>;
comments: GitHubCommentDetail[];
checks: Array<{ name: string; status: string; conclusion?: string; detailsUrl?: string }>;
}
@@ -2452,7 +2464,7 @@ Per-issue detail for the Import Tasks issue preview pane. Mirrors apiFetchGitHub
Issues have no checks rollup, so only `comments` is returned.
*/
export interface GitHubIssueDetail {
comments: Array<{ author: string; body: string; createdAt: string }>;
comments: GitHubCommentDetail[];
}
/** Fetch the full comment thread for a single GitHub issue (called on selection in the import preview). */

View File

@@ -940,13 +940,157 @@ a.github-import-pr-check-name:hover {
font-size: 12px;
font-weight: 600;
color: var(--text);
margin-bottom: var(--space-xs);
}
.github-import-pr-comment__body {
color: var(--text);
}
/*
FNXC:GitHubImport 2026-06-23-03:30:
Per-comment meta row: avatar, author, human/bot badge, and a readable timestamp. The active comment briefly highlights when reached via prev/next nav.
Across the thread: a top filter (All/Human/Bot) and prev/next chevrons live in the comments header. Theme tokens only.
*/
.github-import-pr-comment__meta {
display: flex;
align-items: center;
gap: var(--space-xs);
margin-bottom: var(--space-xs);
flex-wrap: wrap;
}
.github-import-comment__avatar {
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
flex: 0 0 auto;
border-radius: 50%;
overflow: hidden;
background: var(--surface);
border: 1px solid var(--border);
color: var(--text-muted);
}
.github-import-comment__avatar-img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.github-import-comment__type-badge {
display: inline-flex;
align-items: center;
gap: 3px;
padding: 1px 6px;
border-radius: var(--radius-sm);
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
background: var(--surface);
border: 1px solid var(--border);
}
.github-import-comment__type-badge--human {
color: var(--text-muted);
border-color: color-mix(in srgb, var(--text-muted) 40%, transparent);
}
.github-import-comment__type-badge--bot {
color: var(--warning, var(--accent));
border-color: color-mix(in srgb, var(--warning, var(--accent)) 40%, transparent);
}
.github-import-comment__time {
font-size: 11px;
color: var(--text-dim);
margin-left: auto;
}
.github-import-pr-comment--active {
outline: 2px solid var(--accent);
outline-offset: 2px;
transition: outline-color 0.3s ease;
}
.github-import-pr-comments__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-sm);
}
.github-import-comments-nav {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
}
.github-import-comments-nav__pos {
font-size: 11px;
color: var(--text-muted);
min-width: 36px;
text-align: center;
}
.github-import-comments-nav__btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
padding: 0;
border-radius: var(--radius-sm);
background: var(--surface);
border: 1px solid var(--border);
color: var(--text);
cursor: pointer;
}
.github-import-comments-nav__btn:hover:not(:disabled) {
border-color: var(--accent);
color: var(--accent);
}
.github-import-comments-nav__btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.github-import-comments-filter {
display: inline-flex;
align-items: center;
gap: 4px;
margin: 0 0 var(--space-sm);
padding: 2px;
border-radius: var(--radius-md);
background: var(--surface);
border: 1px solid var(--border);
}
.github-import-comments-filter__chip {
padding: 2px 10px;
border: none;
background: transparent;
border-radius: var(--radius-sm);
font-size: 11px;
font-weight: 600;
color: var(--text-muted);
cursor: pointer;
}
.github-import-comments-filter__chip:hover {
color: var(--text);
}
.github-import-comments-filter__chip.active {
background: var(--accent);
color: var(--accent-contrast, #fff);
}
/* Back button - hidden on desktop by default */
.github-import-back-button {
display: none;

View File

@@ -1,5 +1,5 @@
import "./GitHubImportModal.css";
import { useState, useEffect, useCallback, useRef, type CSSProperties, type KeyboardEvent as ReactKeyboardEvent, type PointerEvent as ReactPointerEvent } from "react";
import { useState, useEffect, useCallback, useRef, useMemo, type CSSProperties, type KeyboardEvent as ReactKeyboardEvent, type PointerEvent as ReactPointerEvent } from "react";
import { useTranslation } from "react-i18next";
import type { Task } from "@fusion/core";
import { getErrorMessage } from "@fusion/core";
@@ -16,11 +16,13 @@ import {
type GitHubPull,
type GitHubPullDetail,
type GitHubIssueDetail,
type GitHubCommentDetail,
type GitRemote,
} from "../api";
import { Loader2, RefreshCw, ArrowLeft, GitPullRequest, CircleDot } from "lucide-react";
import { Loader2, RefreshCw, ArrowLeft, GitPullRequest, CircleDot, ChevronUp, ChevronDown, Bot, User } from "lucide-react";
import { GithubIcon } from "./GithubIcon";
import { MailboxMessageContent } from "./MailboxMessageContent";
import type { TFunction } from "i18next";
import { useModalResizePersist } from "../hooks/useModalResizePersist";
import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
import { useOverlayDismiss } from "../hooks/useOverlayDismiss";
@@ -72,6 +74,230 @@ function clampListPaneWidth(width: number, containerWidth = 0) {
return Math.max(GITHUB_IMPORT_LIST_PANE_MIN_WIDTH, Math.min(maxWidth, width));
}
/*
FNXC:GitHubImport 2026-06-23-03:30:
Comment-thread filter modes: DEFAULT is "all" so both human AND bot comments show. "human"/"bot" narrow the thread.
*/
type CommentFilter = "all" | "human" | "bot";
/**
* FNXC:GitHubImport 2026-06-23-03:30:
* Format a comment's createdAt ISO into a readable timestamp (e.g. "Jun 23, 2026, 3:15 PM") via toLocaleString.
* Returns "" for missing/invalid timestamps so the UI can omit the label rather than render "Invalid Date".
*/
function formatCommentTimestamp(iso: string | undefined): string {
if (!iso) return "";
const ms = Date.parse(iso);
if (!Number.isFinite(ms)) return "";
return new Date(ms).toLocaleString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
});
}
/*
FNXC:GitHubImport 2026-06-23-03:30:
Shared comment-thread renderer for BOTH the PR (.github-import-pr-comments) and Issue (.github-import-issue-comments) preview sections.
Adds, per comment: avatar (img with generic User/Bot lucide fallback on load error), author name, a readable createdAt timestamp (title = full ISO), and a human/bot badge (data-comment-author-type).
Across the thread: a top filter (All/Human/Bot, default All shows both) and prev/next chevrons that scroll to + briefly highlight the active comment (tracked via a current index that clamps to the filtered list).
The body still renders via MailboxMessageContent. Test ids: github-import-comment (per comment), github-import-comments-filter, github-import-comment-prev/-next.
*/
function CommentsThread({
comments,
loading,
error,
sectionClassName,
sectionTestId,
loadingTestId,
errorTestId,
emptyTestId,
bodyTestId,
t,
}: {
comments: GitHubCommentDetail[];
loading: boolean;
error: string | null;
sectionClassName: string;
sectionTestId: string;
loadingTestId: string;
errorTestId: string;
emptyTestId: string;
bodyTestId: string;
t: TFunction<"app">;
}) {
const [filter, setFilter] = useState<CommentFilter>("all");
// Index into the FILTERED list for prev/next navigation; clamped whenever the filtered list changes.
const [activeIndex, setActiveIndex] = useState(0);
const commentRefs = useRef<Array<HTMLLIElement | null>>([]);
// Avatar URLs that failed to load fall back to a generic lucide icon.
const [brokenAvatars, setBrokenAvatars] = useState<Set<string>>(new Set());
const filtered = useMemo(() => {
if (filter === "human") return comments.filter((c) => !c.authorIsBot);
if (filter === "bot") return comments.filter((c) => c.authorIsBot);
return comments;
}, [comments, filter]);
// Keep the active index within the filtered range as filter/data changes.
useEffect(() => {
setActiveIndex((current) => (filtered.length === 0 ? 0 : Math.min(current, filtered.length - 1)));
}, [filtered.length]);
const scrollToIndex = useCallback((index: number) => {
const el = commentRefs.current[index];
if (!el) return;
if (typeof el.scrollIntoView === "function") {
el.scrollIntoView({ behavior: "smooth", block: "nearest" });
}
// Brief highlight: add then remove a class so the destination comment flashes.
el.classList.add("github-import-pr-comment--active");
window.setTimeout(() => el.classList.remove("github-import-pr-comment--active"), 1200);
}, []);
const goPrev = useCallback(() => {
setActiveIndex((current) => {
const next = Math.max(0, current - 1);
scrollToIndex(next);
return next;
});
}, [scrollToIndex]);
const goNext = useCallback(() => {
setActiveIndex((current) => {
const next = Math.min(filtered.length - 1, current + 1);
scrollToIndex(next);
return next;
});
}, [scrollToIndex, filtered.length]);
const renderFilter = (
<div className="github-import-comments-filter" data-testid="github-import-comments-filter" role="group" aria-label={t("git.filterCommentsAriaLabel", "Filter comments by author type")}>
{(["all", "human", "bot"] as CommentFilter[]).map((mode) => (
<button
key={mode}
type="button"
className={`github-import-comments-filter__chip ${filter === mode ? "active" : ""}`}
aria-pressed={filter === mode}
data-filter={mode}
onClick={() => setFilter(mode)}
>
{mode === "all"
? t("git.commentFilterAll", "All")
: mode === "human"
? t("git.commentFilterHuman", "Human")
: t("git.commentFilterBot", "Bot")}
</button>
))}
</div>
);
return (
<div className={sectionClassName} data-testid={sectionTestId}>
<div className="github-import-pr-comments__header">
<h5 className="preview-section-heading">{t("git.commentsHeading", "Comments")}</h5>
{/* Prev/next chevrons jump to the previous/next comment in the (filtered) thread. */}
{filtered.length > 1 && (
<div className="github-import-comments-nav" role="group" aria-label={t("git.commentNavAriaLabel", "Navigate comments")}>
<button
type="button"
className="github-import-comments-nav__btn"
data-testid="github-import-comment-prev"
onClick={goPrev}
disabled={activeIndex <= 0}
aria-label={t("git.commentPrevAriaLabel", "Previous comment")}
title={t("git.commentPrevAriaLabel", "Previous comment")}
>
<ChevronUp size={14} aria-hidden="true" />
</button>
<span className="github-import-comments-nav__pos" aria-live="polite">
{t("git.commentNavPosition", "{{current}} / {{total}}", { current: activeIndex + 1, total: filtered.length })}
</span>
<button
type="button"
className="github-import-comments-nav__btn"
data-testid="github-import-comment-next"
onClick={goNext}
disabled={activeIndex >= filtered.length - 1}
aria-label={t("git.commentNextAriaLabel", "Next comment")}
title={t("git.commentNextAriaLabel", "Next comment")}
>
<ChevronDown size={14} aria-hidden="true" />
</button>
</div>
)}
</div>
{/* Filter is always visible (above the thread) so the user can narrow Human/Bot at any time; default All shows both. */}
{!loading && !error && comments.length > 0 && renderFilter}
{loading ? (
<div className="preview-detail-loading" data-testid={loadingTestId}>
<Loader2 size={14} className="spin" aria-hidden="true" />
<span>{t("git.loadingComments", "Loading comments…")}</span>
</div>
) : error ? (
<div className="preview-detail-error" data-testid={errorTestId}>{error}</div>
) : filtered.length > 0 ? (
<ul className="github-import-pr-comments__list">
{filtered.map((comment, idx) => {
const authorType = comment.authorIsBot ? "bot" : "human";
const timestamp = formatCommentTimestamp(comment.createdAt);
const avatarKey = `${comment.author}-${idx}`;
const showAvatarImg = comment.authorAvatarUrl && !brokenAvatars.has(avatarKey);
return (
<li
key={idx}
ref={(el) => { commentRefs.current[idx] = el; }}
className="github-import-pr-comment github-import-comment"
data-testid="github-import-comment"
data-comment-author-type={authorType}
>
<div className="github-import-pr-comment__meta">
<span className="github-import-comment__avatar" aria-hidden="true">
{showAvatarImg ? (
<img
src={comment.authorAvatarUrl}
alt={t("git.commentAvatarAlt", "{{author}} avatar", { author: comment.author })}
className="github-import-comment__avatar-img"
onError={() => setBrokenAvatars((prev) => new Set(prev).add(avatarKey))}
/>
) : comment.authorIsBot ? (
<Bot size={16} aria-hidden="true" />
) : (
<User size={16} aria-hidden="true" />
)}
</span>
<span className="github-import-pr-comment__author">{comment.author}</span>
<span className={`github-import-comment__type-badge github-import-comment__type-badge--${authorType}`}>
{comment.authorIsBot ? <Bot size={11} aria-hidden="true" /> : <User size={11} aria-hidden="true" />}
<span>{comment.authorIsBot ? t("git.commentBot", "Bot") : t("git.commentHuman", "Human")}</span>
</span>
{timestamp && (
<time className="github-import-comment__time" dateTime={comment.createdAt} title={comment.createdAt}>
{timestamp}
</time>
)}
</div>
<MailboxMessageContent
className="github-import-pr-comment__body preview-body--markdown"
content={comment.body || t("git.noCommentBody", "(empty comment)")}
testId={bodyTestId}
/>
</li>
);
})}
</ul>
) : comments.length > 0 ? (
/* All comments filtered out by the current Human/Bot filter. */
<div className="preview-detail-empty" data-testid={emptyTestId}>{t("git.noCommentsForFilter", "No comments match the filter")}</div>
) : (
<div className="preview-detail-empty" data-testid={emptyTestId}>{t("git.noComments", "No comments")}</div>
)}
</div>
);
}
/*
FNXC:GitHubImport 2026-06-22-18:30:
The Import-from-GitHub preview pane must show the FULL selected issue/PR, not a truncated snapshot.
@@ -1179,32 +1405,18 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId,
Comments render BELOW the issue body inside the already-scrollable preview pane. They stream in after the per-issue detail fetch resolves and never block the body above.
Mirrors the PR comments markup/classes; markdown via MailboxMessageContent with an empty state.
*/}
<div className="github-import-pr-comments github-import-issue-comments" data-testid="github-import-issue-comments">
<h5 className="preview-section-heading">{t("git.commentsHeading", "Comments")}</h5>
{issueDetailLoading ? (
<div className="preview-detail-loading" data-testid="github-import-issue-comments-loading">
<Loader2 size={14} className="spin" aria-hidden="true" />
<span>{t("git.loadingComments", "Loading comments…")}</span>
</div>
) : issueDetailError ? (
<div className="preview-detail-error" data-testid="github-import-issue-comments-error">{issueDetailError}</div>
) : issueDetail && issueDetail.comments.length > 0 ? (
<ul className="github-import-pr-comments__list">
{issueDetail.comments.map((comment, idx) => (
<li key={idx} className="github-import-pr-comment">
<div className="github-import-pr-comment__author">{comment.author}</div>
<MailboxMessageContent
className="github-import-pr-comment__body preview-body--markdown"
content={comment.body || t("git.noCommentBody", "(empty comment)")}
testId="github-import-issue-comment-body"
/>
</li>
))}
</ul>
) : (
<div className="preview-detail-empty" data-testid="github-import-issue-comments-empty">{t("git.noComments", "No comments")}</div>
)}
</div>
<CommentsThread
comments={issueDetail?.comments ?? []}
loading={issueDetailLoading}
error={issueDetailError}
sectionClassName="github-import-pr-comments github-import-issue-comments"
sectionTestId="github-import-issue-comments"
loadingTestId="github-import-issue-comments-loading"
errorTestId="github-import-issue-comments-error"
emptyTestId="github-import-issue-comments-empty"
bodyTestId="github-import-issue-comment-body"
t={t}
/>
</div>
) : activeTab === "issues" ? (
<div className="github-import-state github-import-state--idle" data-testid="github-import-preview-empty">
@@ -1291,32 +1503,18 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId,
<div className="preview-detail-empty" data-testid="github-import-pr-checks-empty">{t("git.noChecks", "No checks")}</div>
)}
</div>
<div className="github-import-pr-comments" data-testid="github-import-pr-comments">
<h5 className="preview-section-heading">{t("git.commentsHeading", "Comments")}</h5>
{pullDetailLoading ? (
<div className="preview-detail-loading" data-testid="github-import-pr-comments-loading">
<Loader2 size={14} className="spin" aria-hidden="true" />
<span>{t("git.loadingComments", "Loading comments…")}</span>
</div>
) : pullDetailError ? (
<div className="preview-detail-error" data-testid="github-import-pr-comments-error">{pullDetailError}</div>
) : pullDetail && pullDetail.comments.length > 0 ? (
<ul className="github-import-pr-comments__list">
{pullDetail.comments.map((comment, idx) => (
<li key={idx} className="github-import-pr-comment">
<div className="github-import-pr-comment__author">{comment.author}</div>
<MailboxMessageContent
className="github-import-pr-comment__body preview-body--markdown"
content={comment.body || t("git.noCommentBody", "(empty comment)")}
testId="github-import-pr-comment-body"
/>
</li>
))}
</ul>
) : (
<div className="preview-detail-empty" data-testid="github-import-pr-comments-empty">{t("git.noComments", "No comments")}</div>
)}
</div>
<CommentsThread
comments={pullDetail?.comments ?? []}
loading={pullDetailLoading}
error={pullDetailError}
sectionClassName="github-import-pr-comments"
sectionTestId="github-import-pr-comments"
loadingTestId="github-import-pr-comments-loading"
errorTestId="github-import-pr-comments-error"
emptyTestId="github-import-pr-comments-empty"
bodyTestId="github-import-pr-comment-body"
t={t}
/>
</div>
) : activeTab === "pulls" ? (
<div className="github-import-state github-import-state--idle" data-testid="github-import-preview-empty">

View File

@@ -924,8 +924,8 @@ describe("GitHubImportModal", () => {
vi.mocked(apiFetchGitHubPulls).mockResolvedValueOnce(pulls);
vi.mocked(apiFetchGitHubPullDetail).mockResolvedValueOnce({
comments: [
{ author: "alice", body: "First comment from alice", createdAt: "2024-01-01T00:00:00Z" },
{ author: "bob", body: "Second comment from bob", createdAt: "2024-01-02T00:00:00Z" },
{ author: "alice", body: "First comment from alice", createdAt: "2024-01-01T00:00:00Z", authorIsBot: false, authorAvatarUrl: "https://github.com/alice.png?size=40" },
{ author: "github-actions[bot]", body: "Second comment from bot", createdAt: "2024-01-02T00:00:00Z", authorIsBot: true },
],
checks: [
{ name: "build", status: "completed", conclusion: "success" },
@@ -965,10 +965,111 @@ describe("GitHubImportModal", () => {
expect(checks.querySelector(".github-import-pr-check-pill--success")).toBeTruthy();
// Full comment thread renders, chronological, with authors + bodies.
expect(comments.textContent).toContain("alice");
expect(comments.textContent).toContain("First comment from alice");
expect(comments.textContent).toContain("bob");
expect(comments.textContent).toContain("Second comment from bob");
await waitFor(() => {
expect(comments.textContent).toContain("alice");
expect(comments.textContent).toContain("First comment from alice");
expect(comments.textContent).toContain("github-actions[bot]");
expect(comments.textContent).toContain("Second comment from bot");
});
// FNXC:GitHubImport 2026-06-23-03:30: per-comment testid + human/bot indicator via data-comment-author-type.
const commentEls = within(comments).getAllByTestId("github-import-comment");
expect(commentEls).toHaveLength(2);
expect(commentEls[0].getAttribute("data-comment-author-type")).toBe("human");
expect(commentEls[1].getAttribute("data-comment-author-type")).toBe("bot");
// Human/bot badge labels render.
expect(commentEls[0].textContent).toContain("Human");
expect(commentEls[1].textContent).toContain("Bot");
// Avatar image renders for the human author (with the provided avatar URL).
const avatarImg = commentEls[0].querySelector("img.github-import-comment__avatar-img") as HTMLImageElement | null;
expect(avatarImg?.getAttribute("src")).toBe("https://github.com/alice.png?size=40");
// Readable timestamp renders with the full ISO as the title/datetime.
const timeEl = commentEls[0].querySelector("time");
expect(timeEl?.getAttribute("title")).toBe("2024-01-01T00:00:00Z");
expect(timeEl?.textContent?.length).toBeGreaterThan(0);
});
// FNXC:GitHubImport 2026-06-23-03:30: The Human filter hides bot comments; All (default) shows both.
it("filters bot comments out when the comments filter is set to Human", async () => {
Object.defineProperty(window, "innerWidth", { writable: true, configurable: true, value: 1200 });
const pulls = [
{ number: 11, title: "Filter PR", body: "PR body", html_url: "https://github.com/owner/repo/pull/11", headBranch: "feature", baseBranch: "main" },
];
vi.mocked(fetchGitRemotes).mockResolvedValueOnce(singleRemote);
vi.mocked(apiFetchGitHubPulls).mockResolvedValueOnce(pulls);
vi.mocked(apiFetchGitHubPullDetail).mockResolvedValueOnce({
comments: [
{ author: "alice", body: "human comment text", createdAt: "2024-01-01T00:00:00Z", authorIsBot: false },
{ author: "dependabot[bot]", body: "bot comment text", createdAt: "2024-01-02T00:00:00Z", authorIsBot: true },
],
checks: [],
});
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
fireEvent.click(await screen.findByRole("tab", { name: /Pull Requests/i }));
await waitFor(() => {
expect(screen.getByText("Filter PR")).toBeTruthy();
});
fireEvent.click(screen.getByRole("radio", { name: /Select pull request #11/i }));
const comments = await screen.findByTestId("github-import-pr-comments");
// Default (All): both comments show.
await waitFor(() => {
expect(within(comments).getAllByTestId("github-import-comment")).toHaveLength(2);
});
// Switch to Human: bot comment is hidden.
const filter = within(comments).getByTestId("github-import-comments-filter");
fireEvent.click(within(filter).getByText("Human"));
await waitFor(() => {
const remaining = within(comments).getAllByTestId("github-import-comment");
expect(remaining).toHaveLength(1);
expect(remaining[0].getAttribute("data-comment-author-type")).toBe("human");
});
expect(comments.textContent).not.toContain("bot comment text");
});
// FNXC:GitHubImport 2026-06-23-03:30: Prev/Next nav advances the active comment index across the thread.
it("advances the active comment with the prev/next navigation", async () => {
Object.defineProperty(window, "innerWidth", { writable: true, configurable: true, value: 1200 });
const pulls = [
{ number: 13, title: "Nav PR", body: "PR body", html_url: "https://github.com/owner/repo/pull/13", headBranch: "feature", baseBranch: "main" },
];
vi.mocked(fetchGitRemotes).mockResolvedValueOnce(singleRemote);
vi.mocked(apiFetchGitHubPulls).mockResolvedValueOnce(pulls);
vi.mocked(apiFetchGitHubPullDetail).mockResolvedValueOnce({
comments: [
{ author: "alice", body: "comment one", createdAt: "2024-01-01T00:00:00Z", authorIsBot: false },
{ author: "bob", body: "comment two", createdAt: "2024-01-02T00:00:00Z", authorIsBot: false },
],
checks: [],
});
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
fireEvent.click(await screen.findByRole("tab", { name: /Pull Requests/i }));
await waitFor(() => {
expect(screen.getByText("Nav PR")).toBeTruthy();
});
fireEvent.click(screen.getByRole("radio", { name: /Select pull request #13/i }));
const comments = await screen.findByTestId("github-import-pr-comments");
const prev = await within(comments).findByTestId("github-import-comment-prev");
const next = within(comments).getByTestId("github-import-comment-next");
// At the first comment: prev disabled, next enabled.
expect((prev as HTMLButtonElement).disabled).toBe(true);
expect((next as HTMLButtonElement).disabled).toBe(false);
// Advance to the last comment: next becomes disabled, prev enabled.
fireEvent.click(next);
await waitFor(() => {
expect((next as HTMLButtonElement).disabled).toBe(true);
expect((prev as HTMLButtonElement).disabled).toBe(false);
});
});
// FNXC:GitHubImport 2026-06-23-01:00: Empty detail shows the "No checks"/"No comments" empty states.
@@ -1006,8 +1107,8 @@ describe("GitHubImportModal", () => {
vi.mocked(apiFetchGitHubIssues).mockResolvedValueOnce(issues);
vi.mocked(apiFetchGitHubIssueDetail).mockResolvedValueOnce({
comments: [
{ author: "alice", body: "First issue comment", createdAt: "2024-01-01T00:00:00Z" },
{ author: "bob", body: "Second issue comment", createdAt: "2024-01-02T00:00:00Z" },
{ author: "alice", body: "First issue comment", createdAt: "2024-01-01T00:00:00Z", authorIsBot: false },
{ author: "bob", body: "Second issue comment", createdAt: "2024-01-02T00:00:00Z", authorIsBot: false },
],
});

View File

@@ -16,6 +16,36 @@ import {
const execAsync = promisify(exec);
/*
FNXC:GitHubImport 2026-06-23-03:30:
Resolve a comment author's bot flag + avatar URL for the Import Tasks preview.
isBot: true when the author type is a GitHub Bot (gh GraphQL `__typename === "Bot"` / `is_bot`, REST `user.type === "Bot"`) OR the login ends in `[bot]`.
avatarUrl: prefer the API-provided avatar; otherwise fall back to `https://github.com/{login}.png?size=40` — but NOT for bots, whose `[bot]`-suffixed login does not resolve to a real avatar (the frontend renders a generic bot icon instead of a broken image).
*/
function resolveCommentAuthor(input: {
login: string;
typename?: string | null;
isBot?: boolean | null;
type?: string | null;
avatarUrl?: string | null;
}): { authorIsBot: boolean; authorAvatarUrl?: string } {
const login = input.login || "unknown";
const authorIsBot = Boolean(
input.isBot === true ||
input.typename === "Bot" ||
input.type === "Bot" ||
/\[bot\]$/i.test(login),
);
const providedAvatar = input.avatarUrl?.trim();
let authorAvatarUrl: string | undefined;
if (providedAvatar) {
authorAvatarUrl = providedAvatar;
} else if (!authorIsBot && login !== "unknown") {
authorAvatarUrl = `https://github.com/${encodeURIComponent(login)}.png?size=40`;
}
return { authorIsBot, authorAvatarUrl };
}
function quoteGitArg(value: string): string {
return `'${value.replaceAll("'", "'\\''")}'`;
}
@@ -3579,12 +3609,17 @@ export class GitHubClient {
Returns the issue-level comment thread (author/body/createdAt, chronological) and the status-check rollup mapped to { name, status, conclusion?, detailsUrl? }.
Falls back to REST when gh CLI auth is unavailable; check failures degrade to an empty checks array rather than failing the whole detail.
*/
/*
FNXC:GitHubImport 2026-06-23-03:30:
Comment shape extends to { authorAvatarUrl?, authorIsBot } so the Import Tasks preview can render an avatar and a reliable human/bot badge per comment.
authorIsBot is true when the author type resolves to a GitHub Bot OR the login ends in `[bot]`. authorAvatarUrl is the API-provided avatar when present, else a `https://github.com/{login}.png?size=40` fallback (suppressed for bot logins, whose `[bot]`-suffixed handle does not resolve — the frontend renders a generic bot icon instead).
*/
async getPullRequestDetail(
owner: string,
repo: string,
number: number
): Promise<{
comments: Array<{ author: string; body: string; createdAt: string }>;
comments: Array<{ author: string; body: string; createdAt: string; authorAvatarUrl?: string; authorIsBot: boolean }>;
checks: Array<{ name: string; status: string; conclusion?: string; detailsUrl?: string }>;
}> {
if (this.hasGhAuth()) {
@@ -3608,11 +3643,12 @@ export class GitHubClient {
repo: string,
number: number
): Promise<{
comments: Array<{ author: string; body: string; createdAt: string }>;
comments: Array<{ author: string; body: string; createdAt: string; authorAvatarUrl?: string; authorIsBot: boolean }>;
checks: Array<{ name: string; status: string; conclusion?: string; detailsUrl?: string }>;
}> {
const pr = await runGhJsonAsync<{
comments?: Array<{ author?: { login?: string } | null; body?: string; createdAt?: string }>;
// gh pr view comment authors expose login + avatarUrl; `__typename`/`is_bot` surface bot actors when present.
comments?: Array<{ author?: { login?: string; avatarUrl?: string; __typename?: string; is_bot?: boolean } | null; body?: string; createdAt?: string }>;
// `gh pr view --json statusCheckRollup` returns a flat array of mixed CheckRun/StatusContext shapes.
statusCheckRollup?: Array<{
name?: string;
@@ -3630,11 +3666,16 @@ export class GitHubClient {
"--json", "comments,statusCheckRollup",
]);
const comments = (pr.comments ?? []).map((c) => ({
author: c.author?.login ?? "unknown",
body: c.body ?? "",
createdAt: c.createdAt ?? "",
}));
const comments = (pr.comments ?? []).map((c) => {
const author = c.author?.login ?? "unknown";
const { authorIsBot, authorAvatarUrl } = resolveCommentAuthor({
login: author,
typename: c.author?.__typename,
isBot: c.author?.is_bot,
avatarUrl: c.author?.avatarUrl,
});
return { author, body: c.body ?? "", createdAt: c.createdAt ?? "", authorAvatarUrl, authorIsBot };
});
const checks = (pr.statusCheckRollup ?? []).map((c) => ({
name: c.name ?? c.context ?? "check",
@@ -3652,7 +3693,7 @@ export class GitHubClient {
repo: string,
number: number
): Promise<{
comments: Array<{ author: string; body: string; createdAt: string }>;
comments: Array<{ author: string; body: string; createdAt: string; authorAvatarUrl?: string; authorIsBot: boolean }>;
checks: Array<{ name: string; status: string; conclusion?: string; detailsUrl?: string }>;
}> {
const headers = this.buildHeaders();
@@ -3667,15 +3708,19 @@ export class GitHubClient {
throw new Error(`GitHub API error: ${commentsRes.status} ${commentsRes.statusText}`);
}
const commentData = (await commentsRes.json()) as Array<{
user?: { login?: string } | null;
user?: { login?: string; avatar_url?: string; type?: string } | null;
body?: string;
created_at?: string;
}>;
const comments = commentData.map((c) => ({
author: c.user?.login ?? "unknown",
body: c.body ?? "",
createdAt: c.created_at ?? "",
}));
const comments = commentData.map((c) => {
const author = c.user?.login ?? "unknown";
const { authorIsBot, authorAvatarUrl } = resolveCommentAuthor({
login: author,
type: c.user?.type,
avatarUrl: c.user?.avatar_url,
});
return { author, body: c.body ?? "", createdAt: c.created_at ?? "", authorAvatarUrl, authorIsBot };
});
// Per-check status via the combined check-runs endpoint on the PR head sha.
// Check failures degrade to an empty checks array rather than failing the whole detail.
@@ -3719,7 +3764,7 @@ export class GitHubClient {
repo: string,
number: number
): Promise<{
comments: Array<{ author: string; body: string; createdAt: string }>;
comments: Array<{ author: string; body: string; createdAt: string; authorAvatarUrl?: string; authorIsBot: boolean }>;
}> {
if (this.hasGhAuth()) {
try {
@@ -3742,21 +3787,26 @@ export class GitHubClient {
repo: string,
number: number
): Promise<{
comments: Array<{ author: string; body: string; createdAt: string }>;
comments: Array<{ author: string; body: string; createdAt: string; authorAvatarUrl?: string; authorIsBot: boolean }>;
}> {
const issue = await runGhJsonAsync<{
comments?: Array<{ author?: { login?: string } | null; body?: string; createdAt?: string }>;
comments?: Array<{ author?: { login?: string; avatarUrl?: string; __typename?: string; is_bot?: boolean } | null; body?: string; createdAt?: string }>;
}>([
"issue", "view", String(number),
"--repo", `${owner}/${repo}`,
"--json", "comments",
]);
const comments = (issue.comments ?? []).map((c) => ({
author: c.author?.login ?? "unknown",
body: c.body ?? "",
createdAt: c.createdAt ?? "",
}));
const comments = (issue.comments ?? []).map((c) => {
const author = c.author?.login ?? "unknown";
const { authorIsBot, authorAvatarUrl } = resolveCommentAuthor({
login: author,
typename: c.author?.__typename,
isBot: c.author?.is_bot,
avatarUrl: c.author?.avatarUrl,
});
return { author, body: c.body ?? "", createdAt: c.createdAt ?? "", authorAvatarUrl, authorIsBot };
});
return { comments };
}
@@ -3766,7 +3816,7 @@ export class GitHubClient {
repo: string,
number: number
): Promise<{
comments: Array<{ author: string; body: string; createdAt: string }>;
comments: Array<{ author: string; body: string; createdAt: string; authorAvatarUrl?: string; authorIsBot: boolean }>;
}> {
const headers = this.buildHeaders();
@@ -3779,15 +3829,19 @@ export class GitHubClient {
throw new Error(`GitHub API error: ${commentsRes.status} ${commentsRes.statusText}`);
}
const commentData = (await commentsRes.json()) as Array<{
user?: { login?: string } | null;
user?: { login?: string; avatar_url?: string; type?: string } | null;
body?: string;
created_at?: string;
}>;
const comments = commentData.map((c) => ({
author: c.user?.login ?? "unknown",
body: c.body ?? "",
createdAt: c.created_at ?? "",
}));
const comments = commentData.map((c) => {
const author = c.user?.login ?? "unknown";
const { authorIsBot, authorAvatarUrl } = resolveCommentAuthor({
login: author,
type: c.user?.type,
avatarUrl: c.user?.avatar_url,
});
return { author, body: c.body ?? "", createdAt: c.created_at ?? "", authorAvatarUrl, authorIsBot };
});
return { comments };
}