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:
@@ -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). */
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 },
|
||||
],
|
||||
});
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user