From 35b1838c5fb3afe85408556b37495a4a90a58182 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 22 Jun 2026 11:18:12 -0700 Subject: [PATCH] =?UTF-8?q?feat(dashboard):=20richer=20GitHub=20import=20c?= =?UTF-8?q?omments=20=E2=80=94=20avatar,=20time,=20human/bot=20badge,=20na?= =?UTF-8?q?v,=20filter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- packages/dashboard/app/api/legacy.ts | 16 +- .../app/components/GitHubImportModal.css | 146 ++++++++- .../app/components/GitHubImportModal.tsx | 306 ++++++++++++++---- .../__tests__/GitHubImportModal.test.tsx | 117 ++++++- packages/dashboard/src/github.ts | 114 +++++-- 5 files changed, 604 insertions(+), 95 deletions(-) diff --git a/packages/dashboard/app/api/legacy.ts b/packages/dashboard/app/api/legacy.ts index 8ea4b64f91..9e50900b22 100644 --- a/packages/dashboard/app/api/legacy.ts +++ b/packages/dashboard/app/api/legacy.ts @@ -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). */ diff --git a/packages/dashboard/app/components/GitHubImportModal.css b/packages/dashboard/app/components/GitHubImportModal.css index 0c02259b2f..751225c17f 100644 --- a/packages/dashboard/app/components/GitHubImportModal.css +++ b/packages/dashboard/app/components/GitHubImportModal.css @@ -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; diff --git a/packages/dashboard/app/components/GitHubImportModal.tsx b/packages/dashboard/app/components/GitHubImportModal.tsx index b1b988a3e4..a1f272f6b0 100644 --- a/packages/dashboard/app/components/GitHubImportModal.tsx +++ b/packages/dashboard/app/components/GitHubImportModal.tsx @@ -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("all"); + // Index into the FILTERED list for prev/next navigation; clamped whenever the filtered list changes. + const [activeIndex, setActiveIndex] = useState(0); + const commentRefs = useRef>([]); + // Avatar URLs that failed to load fall back to a generic lucide icon. + const [brokenAvatars, setBrokenAvatars] = useState>(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 = ( +
+ {(["all", "human", "bot"] as CommentFilter[]).map((mode) => ( + + ))} +
+ ); + + return ( +
+
+
{t("git.commentsHeading", "Comments")}
+ {/* Prev/next chevrons jump to the previous/next comment in the (filtered) thread. */} + {filtered.length > 1 && ( +
+ + + {t("git.commentNavPosition", "{{current}} / {{total}}", { current: activeIndex + 1, total: filtered.length })} + + +
+ )} +
+ {/* 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 ? ( +
+
+ ) : error ? ( +
{error}
+ ) : filtered.length > 0 ? ( +
    + {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 ( +
  • { commentRefs.current[idx] = el; }} + className="github-import-pr-comment github-import-comment" + data-testid="github-import-comment" + data-comment-author-type={authorType} + > +
    + + {comment.author} + + {comment.authorIsBot ? + {timestamp && ( + + )} +
    + +
  • + ); + })} +
+ ) : comments.length > 0 ? ( + /* All comments filtered out by the current Human/Bot filter. */ +
{t("git.noCommentsForFilter", "No comments match the filter")}
+ ) : ( +
{t("git.noComments", "No comments")}
+ )} +
+ ); +} + /* 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. */} -
-
{t("git.commentsHeading", "Comments")}
- {issueDetailLoading ? ( -
-
- ) : issueDetailError ? ( -
{issueDetailError}
- ) : issueDetail && issueDetail.comments.length > 0 ? ( -
    - {issueDetail.comments.map((comment, idx) => ( -
  • -
    {comment.author}
    - -
  • - ))} -
- ) : ( -
{t("git.noComments", "No comments")}
- )} -
+ ) : activeTab === "issues" ? (
@@ -1291,32 +1503,18 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId,
{t("git.noChecks", "No checks")}
)}
-
-
{t("git.commentsHeading", "Comments")}
- {pullDetailLoading ? ( -
-
- ) : pullDetailError ? ( -
{pullDetailError}
- ) : pullDetail && pullDetail.comments.length > 0 ? ( -
    - {pullDetail.comments.map((comment, idx) => ( -
  • -
    {comment.author}
    - -
  • - ))} -
- ) : ( -
{t("git.noComments", "No comments")}
- )} -
+ ) : activeTab === "pulls" ? (
diff --git a/packages/dashboard/app/components/__tests__/GitHubImportModal.test.tsx b/packages/dashboard/app/components/__tests__/GitHubImportModal.test.tsx index 1b8247fcbb..39d9143050 100644 --- a/packages/dashboard/app/components/__tests__/GitHubImportModal.test.tsx +++ b/packages/dashboard/app/components/__tests__/GitHubImportModal.test.tsx @@ -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(); + + 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(); + + 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 }, ], }); diff --git a/packages/dashboard/src/github.ts b/packages/dashboard/src/github.ts index 7621dc6d93..7300cf5c6d 100644 --- a/packages/dashboard/src/github.ts +++ b/packages/dashboard/src/github.ts @@ -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 }; }