diff --git a/src/features/knowledge-base/components/ArtifactList.tsx b/src/features/knowledge-base/components/ArtifactList.tsx index 7cf508b20..7b3166932 100644 --- a/src/features/knowledge-base/components/ArtifactList.tsx +++ b/src/features/knowledge-base/components/ArtifactList.tsx @@ -1,6 +1,13 @@ import { memo } from "react"; import { AnimatePresence, motion } from "framer-motion"; -import { FileText, FileCode, CircleDot, GitPullRequest, ChevronRight } from "lucide-react"; +import { + FileText, + FileCode, + CircleDot, + GitPullRequest, + Building2, + ChevronRight, +} from "lucide-react"; import type { Artifact, ArtifactType } from "../types"; import { SpotlightCard } from "../../../components/ui/SpotlightCard"; import { centralSpringToken } from "../../../styles/tokens"; @@ -24,11 +31,24 @@ const getIcon = (type: ArtifactType) => { return ; case "PULL_REQUEST": return ; + case "ORG_METADATA": + // Neutral like COMMIT so it never reads as a status; organizations are + // a distinct shape, not a success/warning condition. + return ; default: return ; } }; +/** + * Human-readable label for the artifact-type chip. The chip shows the raw + * artifact type for every other kind, which reads fine ("COMMIT", "ISSUE"); + * `ORG_METADATA` is the one value that names its storage shape rather than the + * thing itself, so it gets a word a reader would actually use. + */ +const getTypeLabel = (type: ArtifactType): string => + type === "ORG_METADATA" ? "Organization" : type; + const formatDate = (iso: string): string => { return new Date(iso).toLocaleDateString(undefined, { year: "numeric", @@ -71,7 +91,7 @@ const ArtifactCard = memo(function ArtifactCard({ artifact, onSelect }: Artifact

{artifact.title ?? "Untitled"}

- {artifact.artifactType} + {getTypeLabel(artifact.artifactType)} {artifact.sourceSystem} diff --git a/src/features/knowledge-base/components/ArtifactViewerDrawer.tsx b/src/features/knowledge-base/components/ArtifactViewerDrawer.tsx index d8659fccb..ded9be7f2 100644 --- a/src/features/knowledge-base/components/ArtifactViewerDrawer.tsx +++ b/src/features/knowledge-base/components/ArtifactViewerDrawer.tsx @@ -1,5 +1,20 @@ import { useEffect, useReducer, useRef, useMemo, useCallback, type ReactNode } from "react"; -import { Sparkles, ArrowLeft, Loader2, RefreshCw, Trash2, ArrowDown, BookOpen } from "lucide-react"; +import { + Sparkles, + ArrowLeft, + Loader2, + RefreshCw, + Trash2, + ArrowDown, + BookOpen, + Building2, + MapPin, + Globe, + Mail, + Users, + Hash, + Link2, +} from "lucide-react"; import ReactMarkdown, { type Options as ReactMarkdownOptions } from "react-markdown"; import remarkGfm from "remark-gfm"; import remarkMath from "remark-math"; @@ -9,6 +24,11 @@ import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; import { vscDarkPlus } from "react-syntax-highlighter/dist/esm/styles/prism"; import type { Artifact, ArtifactContent, ArtifactSummaryCitation } from "../types"; import { preprocessMarkdown } from "../markdown"; +import { + parseOrgMetadata, + type OrgMetadataArtifactMetadata, + type OrgMetadataTeam, +} from "../orgMetadata"; import { knowledgeService } from "../../../services/knowledgeService"; import { useToast } from "../../../context/useToast"; import { Button } from "../../../components/ui/Button"; @@ -60,6 +80,7 @@ type DrawerAction = | { type: "loadStart" } | { type: "loadSuccess"; content: ArtifactContent } | { type: "loadError"; error: string } + | { type: "skipContentLoad" } | { type: "setMarkdownViewMode"; mode: MarkdownViewMode } | { type: "summarizeStart" } | { type: "summarizeIndexing" } @@ -98,6 +119,10 @@ function drawerReducer(state: DrawerState, action: DrawerAction): DrawerState { return { ...state, isLoading: true, error: null }; case "loadSuccess": return { ...state, isLoading: false, content: action.content }; + case "skipContentLoad": + // ORG_METADATA artifacts have no stored content (their content endpoint + // redirects), so nothing to fetch — just end the loading state. + return { ...state, isLoading: false }; case "loadError": return { ...state, isLoading: false, error: action.error }; case "setMarkdownViewMode": @@ -571,6 +596,188 @@ const getLanguage = (filename?: string | null) => { } }; +/** + * Renders a labeled metadata row (icon + label + value) for the org profile. + */ +function OrgProfileRow({ + icon, + label, + children, +}: { + icon: ReactNode; + label: string; + children: ReactNode; +}) { + return ( +
+ {icon} +
+
{label}
+
{children}
+
+
+ ); +} + +/** GitHub's `blog` is usually a URL; link it, prefixing a scheme when bare. */ +function normalizeUrl(value: string | null): string | null { + const trimmed = value?.trim(); + // Shortest realistic hostname is 4 chars (e.g. a.io); anything shorter + // (including "N/A", "?", whitespace) is not a real URL and must not be linked. + if (!trimmed || trimmed.length < 4) return null; + return /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`; +} + +/** Org profile view, rendered purely from the artifact's `metadata` JSON. + * Teams and members are always fully expanded. */ +function OrgMetadataView({ + metadata, + title, +}: { + metadata: OrgMetadataArtifactMetadata | null; + title: string | null; +}) { + if (!metadata) { + // Known backend gap: the org artifact exists but its metadata couldn't be + // parsed. Show a quiet empty state instead of killing the drawer or falling + // through to the (redirect-following) content fetch. + return ( +
+ +

Organization profile unavailable.

+
+ ); + } + + const blogUrl = normalizeUrl(metadata.blog); + + return ( +
+
+
+ +
+
+

+ {metadata.name || title || "Organization"} +

+ + @{metadata.login} + + {metadata.description && ( +

{metadata.description}

+ )} +
+
+ +
+ {metadata.location && ( + } label="Location"> + {metadata.location} + + )} + {blogUrl && ( + } label="Blog"> + + + {metadata.blog} + + + )} + {metadata.company && ( + } label="Company"> + {metadata.company} + + )} + {metadata.email && ( + } label="Email"> + + {metadata.email} + + + )} + } label="Repositories"> + {metadata.publicRepos !== null && metadata.privateRepos !== null ? ( + <> + {metadata.publicRepos} public · {metadata.privateRepos} private + + ) : ( + "N/A" + )} + +
+ + {metadata.teams && metadata.teams.length > 0 && ( +
+

+ + Teams +

+
+ {metadata.teams.map((team: OrgMetadataTeam) => ( +
+

{team.name}

+ {team.members.length > 0 && ( +
    + {team.members.map((member) => ( +
  • + {member.login} +
  • + ))} +
+ )} +
+ ))} +
+
+ )} + + {metadata.members.length > 0 && ( +
+

+ + Members + + {metadata.members.length} + +

+ +
+ )} +
+ ); +} + /** * ArtifactViewerDrawer * @@ -616,6 +823,21 @@ export function ArtifactViewerDrawer({ // confirmation for the previous artifact can't be carried over. dispatch({ type: "reset" }); + // ORG_METADATA artifacts carry no stored bytes: the backend's content + // endpoint answers a 302 redirect to the org's GitHub page, and following it + // would land the drawer on GitHub's HTML. They render purely from + // `artifact.metadata` (org profile/teams/members), so skip the fetch entirely. + if (artifact.artifactType === "ORG_METADATA") { + dispatch({ type: "skipContentLoad" }); + const myGeneration = summarizeGenerationRef.current; + return () => { + isMounted = false; + summarizeGenerationRef.current = myGeneration + 1; + abortRef.current?.abort(); + abortRef.current = null; + }; + } + knowledgeService .getArtifactContent(projectId, artifact.id, artifact.sourceSystem) .then((data) => { @@ -870,7 +1092,12 @@ export function ArtifactViewerDrawer({ content?.mimeType === "application/pdf" || (content?.mimeType.startsWith("image/") ?? false); const canHighlight = highlightLines && highlightLines.length > 0 && !isPdfOrImage; - const actionsContent = viewMode === "raw" && ( + const orgMetadata = useMemo( + () => (artifact?.artifactType === "ORG_METADATA" ? parseOrgMetadata(artifact.metadata) : null), + [artifact], + ); + + const actionsContent = viewMode === "raw" && artifact?.artifactType !== "ORG_METADATA" && (
{isMarkdownArtifact && content && (
@@ -941,6 +1168,10 @@ export function ArtifactViewerDrawer({

Error loading content

{error}

+ ) : viewMode === "raw" && artifact?.artifactType === "ORG_METADATA" ? ( +
+ +
) : viewMode === "raw" ? (
{isLoading ? ( diff --git a/src/features/knowledge-base/hooks/useKnowledgeBase.ts b/src/features/knowledge-base/hooks/useKnowledgeBase.ts index ffb415803..c281eb97d 100644 --- a/src/features/knowledge-base/hooks/useKnowledgeBase.ts +++ b/src/features/knowledge-base/hooks/useKnowledgeBase.ts @@ -1,7 +1,7 @@ import { useState, useEffect, useMemo, useCallback, useRef, useDeferredValue } from "react"; import { knowledgeService } from "../../../services/knowledgeService"; import type { Artifact } from "../types"; -import type { KnowledgeTab } from "../components/ArtifactFilters"; +import type { KnowledgeTab } from "../tabs"; const ITEMS_PER_PAGE = 20; @@ -107,6 +107,9 @@ export function useKnowledgeBase(projectId: string | null) { case "COMMITS": matchesTab = artifact.artifactType === "COMMIT"; break; + case "ORGANIZATIONS": + matchesTab = artifact.artifactType === "ORG_METADATA"; + break; } return matchesSearch && matchesTab; diff --git a/src/features/knowledge-base/orgMetadata.ts b/src/features/knowledge-base/orgMetadata.ts new file mode 100644 index 000000000..1c4bbcab2 --- /dev/null +++ b/src/features/knowledge-base/orgMetadata.ts @@ -0,0 +1,106 @@ +/** + * Type mapping + tolerant parser for the metadata JSON string the backend ships on + * `ORG_METADATA` artifacts. + * + * The backend DTO (`GithubOrgMetadataArtifactMetadata` in sprintstart-backend) + * serializes the org profile, its teams and members into the artifact's `metadata` + * field — the only place that data lives; the artifact has no stored bytes and its + * content endpoint 302-redirects to the org's GitHub page. Centralizing the parse + * makes the mapping unit-testable and keeps consumers (the viewer drawer) free of + * ad-hoc `JSON.parse` + shape handling. + * + * Shape mirrors the backend contract exactly; optional fields are `null`-able the + * way GitHub's API returns them. + */ + +export interface OrgMetadataArtifactMetadata { + /** GitHub login (also the artifact `sourceId`). */ + login: string; + /** Org display name (also the artifact `title`). */ + name: string; + description: string | null; + company: string | null; + blog: string | null; + location: string | null; + email: string | null; + publicRepos: number | null; + privateRepos: number | null; + /** Present only when the org has exposed teams to the ingest GitHub app. */ + teams: OrgMetadataTeam[] | null; + /** Every member visible to the ingest GitHub app. */ + members: OrgMetadataMember[]; +} + +export interface OrgMetadataTeam { + name: string; + slug: string | null; + orgLogin: string; + orgName: string | null; + members: OrgMetadataTeamMember[]; +} + +export interface OrgMetadataTeamMember { + login: string; + name: string | null; +} + +export interface OrgMetadataMember { + login: string; + url: string; +} + +/** + * Parses an `ORG_METADATA` artifact's `metadata` JSON string. + * + * Tolerant by design: returns `null` for any value that cannot be turned into a + * usable org profile — `null`/`undefined`, empty string, malformed JSON, or JSON + * that is not a plain object (e.g. `"123"`). This lets callers own the empty state + * without co-opting the "nothing here" of a failed parse. + * + * @param json The raw `artifact.metadata` string (may be omitted). + * @returns The parsed org metadata, or `null` when the input is not usable. + */ +export function parseOrgMetadata( + json: string | null | undefined, +): OrgMetadataArtifactMetadata | null { + if (typeof json !== "string" || json.trim() === "") return null; + + let parsed: unknown; + try { + parsed = JSON.parse(json); + } catch { + // A malformed blob (never expected from the backend, but cheap to guard + // against) must not crash the viewer — degrade to the same empty state. + console.warn("Ignoring unparseable ORG_METADATA artifact metadata", json); + return null; + } + + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + return null; + } + + const p = parsed as Record; + if (typeof p["login"] !== "string" || !Array.isArray(p["members"])) { + return null; + } + + // Same guard one level down: `teams` is optional, but the viewer dereferences + // `team.members.length` / `.map()` on every entry it does get, so a team + // without a members array is the identical TypeError the check above prevents. + // Rejecting the whole payload (rather than the one team) keeps the contract + // the callers already rely on: unusable metadata means the quiet empty state. + const teams = p["teams"]; + if (teams !== null && teams !== undefined) { + if (!Array.isArray(teams)) return null; + + const everyTeamUsable = teams.every( + (team) => + typeof team === "object" && + team !== null && + Array.isArray((team as Record)["members"]), + ); + if (!everyTeamUsable) return null; + } + + return parsed as OrgMetadataArtifactMetadata; +} diff --git a/src/features/knowledge-base/tabs.ts b/src/features/knowledge-base/tabs.ts index fa6560213..cda65ae4d 100644 --- a/src/features/knowledge-base/tabs.ts +++ b/src/features/knowledge-base/tabs.ts @@ -1,4 +1,5 @@ -export type KnowledgeTab = "ALL" | "UPLOADS" | "PR" | "ISSUES" | "FILES" | "COMMITS"; +export type KnowledgeTab = + "ALL" | "UPLOADS" | "PR" | "ISSUES" | "FILES" | "COMMITS" | "ORGANIZATIONS"; /** * Left-to-right order of the artifact type tabs. @@ -17,6 +18,7 @@ export const TABS: { id: KnowledgeTab; label: string }[] = [ { id: "ISSUES", label: "Issues" }, { id: "FILES", label: "Files" }, { id: "COMMITS", label: "Commits" }, + { id: "ORGANIZATIONS", label: "Organizations" }, ]; /** diff --git a/src/features/knowledge-base/types.ts b/src/features/knowledge-base/types.ts index a79731891..4deffaaef 100644 --- a/src/features/knowledge-base/types.ts +++ b/src/features/knowledge-base/types.ts @@ -2,7 +2,7 @@ * Defines the specific entity type of an artifact. * Used by the UI to determine icon representations and filtering logic. */ -export type ArtifactType = "COMMIT" | "FILE" | "ISSUE" | "PULL_REQUEST"; +export type ArtifactType = "COMMIT" | "FILE" | "ISSUE" | "PULL_REQUEST" | "ORG_METADATA"; /** * Origin source of the artifact data. @@ -29,6 +29,18 @@ export interface Artifact { updatedAtSource: string | null; contentHash: string | null; ingestionRunId: string | null; + /** + * Backend-supplied metadata as a JSON string. Optional here rather than + * required: the backend defaults it to `"{}"`, but older artifacts predating + * the field — and every fixture that omits it — must stay assignable. + * For `ORG_METADATA` artifacts it carries the GitHub org profile, teams and + * members — see + * [`parseOrgMetadata`](./orgMetadata). The artifact *content* endpoint + * (`GET /artifacts/{id}/content`) is a 302 redirect to the org's GitHub page for + * this type and holds no stored bytes, so org artifacts must be rendered purely + * from this field (see `ArtifactViewerDrawer`). + */ + metadata?: string; } /** diff --git a/tests/unit/features/knowledge-base/components/ArtifactViewerDrawer.test.tsx b/tests/unit/features/knowledge-base/components/ArtifactViewerDrawer.test.tsx index 6b862cf32..c7fcbb401 100644 --- a/tests/unit/features/knowledge-base/components/ArtifactViewerDrawer.test.tsx +++ b/tests/unit/features/knowledge-base/components/ArtifactViewerDrawer.test.tsx @@ -572,4 +572,74 @@ describe("ArtifactViewerDrawer", () => { expect(await screen.findByText("Network failure")).toBeInTheDocument(); }); }); + + describe("ORG_METADATA artifacts", () => { + const orgMetadata = JSON.stringify({ + login: "sprintstart", + name: "SprintStart", + description: "Campus project", + company: null, + blog: "https://sprintstart.dev", + location: "Berlin", + email: null, + publicRepos: 12, + privateRepos: 3, + teams: [ + { + name: "Platform", + slug: "platform", + orgLogin: "sprintstart", + orgName: "SprintStart", + members: [{ login: "alice", name: "Alice" }], + }, + ], + members: [{ login: "alice", url: "https://github.com/alice" }], + }); + + it("renders the org profile from metadata and never fetches content", async () => { + const { knowledgeService } = await import("../../../../../src/services/knowledgeService"); + renderDrawer( + createArtifact({ + artifactType: "ORG_METADATA", + title: "SprintStart", + sourceSystem: "GITHUB", + metadata: orgMetadata, + }), + { canDelete: true }, + ); + + expect(await screen.findByTestId("org-metadata-view")).toBeInTheDocument(); + expect(screen.getAllByText("SprintStart").length).toBeGreaterThan(0); + expect(screen.getByText("@sprintstart")).toBeInTheDocument(); + expect(screen.getByText("Campus project")).toBeInTheDocument(); + expect(screen.getByText("Berlin")).toBeInTheDocument(); + expect(screen.getByText("12 public · 3 private")).toBeInTheDocument(); + // Teams + members come from the metadata, not from a fetched body. + expect(screen.getByText("Platform")).toBeInTheDocument(); + expect(screen.getAllByText("alice").length).toBe(2); + + // The content endpoint 302-redirects for this type; the drawer must not + // follow it into GitHub's HTML. + expect(knowledgeService.getArtifactContent).not.toHaveBeenCalled(); + // No summarise (nothing summarisable) and no delete (not an UPLOAD artifact). + expect(screen.queryByTestId("summarise-btn")).not.toBeInTheDocument(); + expect(screen.queryByTestId("delete-artifact-btn")).not.toBeInTheDocument(); + }); + + it("shows a quiet empty state when the metadata JSON is unusable", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + renderDrawer( + createArtifact({ + artifactType: "ORG_METADATA", + title: "SprintStart", + sourceSystem: "GITHUB", + metadata: "{not json", + }), + ); + + expect(await screen.findByText("Organization profile unavailable.")).toBeInTheDocument(); + expect(screen.queryByTestId("summarise-btn")).not.toBeInTheDocument(); + expect(warn).toHaveBeenCalled(); + }); + }); }); diff --git a/tests/unit/features/knowledge-base/hooks/useKnowledgeBase.test.ts b/tests/unit/features/knowledge-base/hooks/useKnowledgeBase.test.ts index f55bd52ee..569dadb92 100644 --- a/tests/unit/features/knowledge-base/hooks/useKnowledgeBase.test.ts +++ b/tests/unit/features/knowledge-base/hooks/useKnowledgeBase.test.ts @@ -9,11 +9,16 @@ vi.mock("../../../../../src/services/knowledgeService", () => ({ }, })); -function makeArtifact(id: string, title: string): Artifact { +function makeArtifact( + id: string, + title: string, + artifactType: Artifact["artifactType"] = "FILE", + metadata?: string, +): Artifact { return { id, title, - artifactType: "FILE", + artifactType, sourceSystem: "GITHUB", sourceId: "src", sourceUrl: null, @@ -24,6 +29,7 @@ function makeArtifact(id: string, title: string): Artifact { updatedAtSource: "2024-01-01", contentHash: null, ingestionRunId: null, + ...(metadata !== undefined ? { metadata } : {}), }; } @@ -165,6 +171,76 @@ describe("useKnowledgeBase", () => { expect(result.current.filteredArtifacts[0].sourceSystem).toBe("UPLOAD"); }); + it("filters by tab (ORGANIZATIONS)", async () => { + const { knowledgeService } = await import("../../../../../src/services/knowledgeService"); + vi.mocked(knowledgeService.getUnifiedArtifacts).mockResolvedValue([ + makeArtifact("a1", "readme.md"), + makeArtifact("a2", "SprintStart", "ORG_METADATA", JSON.stringify({ login: "SprintStart" })), + ]); + + const { result } = renderHook(() => useKnowledgeBase("proj-1")); + + await waitFor(() => { + expect(result.current.artifacts).toHaveLength(2); + }); + + act(() => { + result.current.handleTabChange("ORGANIZATIONS"); + }); + + expect(result.current.filteredArtifacts).toHaveLength(1); + expect(result.current.filteredArtifacts[0].artifactType).toBe("ORG_METADATA"); + }); + + it("combines the ORGANIZATIONS tab with a search query", async () => { + const { knowledgeService } = await import("../../../../../src/services/knowledgeService"); + vi.mocked(knowledgeService.getUnifiedArtifacts).mockResolvedValue([ + makeArtifact("a1", "readme.md"), + makeArtifact("a2", "SprintStart", "ORG_METADATA"), + makeArtifact("a3", "Acme Corp", "ORG_METADATA"), + ]); + + const { result } = renderHook(() => useKnowledgeBase("proj-1")); + + await waitFor(() => { + expect(result.current.artifacts).toHaveLength(3); + }); + + act(() => { + result.current.handleTabChange("ORGANIZATIONS"); + result.current.handleSearchChange("sprintstart"); + }); + + expect(result.current.filteredArtifacts).toHaveLength(1); + expect(result.current.filteredArtifacts[0].title).toBe("SprintStart"); + }); + + it("resets to page 1 when switching to the ORGANIZATIONS tab", async () => { + const { knowledgeService } = await import("../../../../../src/services/knowledgeService"); + const artifacts: Artifact[] = Array.from({ length: 30 }, (_, i) => + makeArtifact(`o${i}`, `org-${i}`, "ORG_METADATA"), + ); + vi.mocked(knowledgeService.getUnifiedArtifacts).mockResolvedValue(artifacts); + + const { result } = renderHook(() => useKnowledgeBase("proj-1")); + + await waitFor(() => { + expect(result.current.artifacts).toHaveLength(30); + }); + + act(() => { + result.current.setCurrentPage(2); + }); + expect(result.current.currentPage).toBe(2); + + act(() => { + result.current.handleTabChange("ORGANIZATIONS"); + }); + + expect(result.current.currentPage).toBe(1); + expect(result.current.filteredArtifacts).toHaveLength(30); + }); + it("clears filters", async () => { const { knowledgeService } = await import("../../../../../src/services/knowledgeService"); vi.mocked(knowledgeService.getUnifiedArtifacts).mockResolvedValue([ diff --git a/tests/unit/features/knowledge-base/orgMetadata.test.ts b/tests/unit/features/knowledge-base/orgMetadata.test.ts new file mode 100644 index 000000000..84903b991 --- /dev/null +++ b/tests/unit/features/knowledge-base/orgMetadata.test.ts @@ -0,0 +1,127 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { parseOrgMetadata } from "../../../../src/features/knowledge-base/orgMetadata"; + +describe("parseOrgMetadata", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("parses a fully-populated org profile", () => { + const parsed = parseOrgMetadata( + JSON.stringify({ + login: "sprintstart", + name: "SprintStart", + description: "Campus project", + company: "Uni", + blog: "https://sprintstart.dev", + location: "Berlin", + email: "ops@sprintstart.dev", + publicRepos: 12, + privateRepos: 3, + teams: null, + members: [], + }), + ); + + expect(parsed).not.toBeNull(); + expect(parsed?.login).toBe("sprintstart"); + expect(parsed?.name).toBe("SprintStart"); + expect(parsed?.description).toBe("Campus project"); + expect(parsed?.location).toBe("Berlin"); + expect(parsed?.publicRepos).toBe(12); + expect(parsed?.privateRepos).toBe(3); + expect(parsed?.teams).toBeNull(); + expect(parsed?.members).toEqual([]); + }); + + it("preserves the nested teams/members shape", () => { + const parsed = parseOrgMetadata( + JSON.stringify({ + login: "sprintstart", + name: "SprintStart", + description: null, + company: null, + blog: null, + location: null, + email: null, + publicRepos: 1, + privateRepos: 1, + teams: [ + { + name: "Platform", + slug: "platform", + orgLogin: "sprintstart", + orgName: "SprintStart", + members: [ + { login: "alice", name: "Alice" }, + { login: "bob", name: null }, + ], + }, + ], + members: [ + { login: "alice", url: "https://github.com/alice" }, + { login: "bob", url: "https://github.com/bob" }, + ], + }), + ); + + expect(parsed?.teams).toHaveLength(1); + expect(parsed?.teams?.[0].slug).toBe("platform"); + expect(parsed?.teams?.[0].members).toHaveLength(2); + expect(parsed?.teams?.[0].members[1]).toEqual({ login: "bob", name: null }); + expect(parsed?.members).toHaveLength(2); + expect(parsed?.members[0].url).toBe("https://github.com/alice"); + }); + + it("returns null for missing or blank input", () => { + expect(parseOrgMetadata(undefined)).toBeNull(); + expect(parseOrgMetadata(null)).toBeNull(); + expect(parseOrgMetadata("")).toBeNull(); + expect(parseOrgMetadata(" ")).toBeNull(); + }); + + it("returns null for malformed JSON instead of throwing", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + expect(parseOrgMetadata("{not json")).toBeNull(); + expect(warn).toHaveBeenCalled(); + }); + + it("returns null for JSON that is not a plain object", () => { + expect(parseOrgMetadata("123")).toBeNull(); + expect(parseOrgMetadata('"hello"')).toBeNull(); + expect(parseOrgMetadata("[]")).toBeNull(); + }); + + it("returns null when a team is missing its members array", () => { + // The viewer maps over `team.members` for every team it renders, so a team + // without one is the same TypeError the top-level `members` guard prevents. + expect( + parseOrgMetadata( + JSON.stringify({ + login: "org", + members: [], + teams: [{ name: "Platform", slug: "platform", orgLogin: "org", orgName: null }], + }), + ), + ).toBeNull(); + + expect( + parseOrgMetadata(JSON.stringify({ login: "org", members: [], teams: "Platform" })), + ).toBeNull(); + }); + + it("accepts a payload whose teams are absent or explicitly null", () => { + expect(parseOrgMetadata(JSON.stringify({ login: "org", members: [] }))?.login).toBe("org"); + expect( + parseOrgMetadata(JSON.stringify({ login: "org", members: [], teams: null }))?.login, + ).toBe("org"); + }); + + it("returns null when required fields are missing or the wrong type", () => { + // members is declared non-nullable — a null value must be rejected before the cast + // to prevent a TypeError when the viewer calls members.length / members.map(). + expect(parseOrgMetadata(JSON.stringify({ login: "org", members: null }))).toBeNull(); + // login is the primary key — an object without it must be rejected. + expect(parseOrgMetadata(JSON.stringify({ members: [] }))).toBeNull(); + }); +});