diff --git a/doc/architecture.md b/doc/architecture.md index 2381fcf..315a7a2 100644 --- a/doc/architecture.md +++ b/doc/architecture.md @@ -64,6 +64,27 @@ flowchart LR Drizzle <--> Turso ``` +## Organization issue discovery + +`/organizations` provides a dedicated public dashboard to search open-source +issues across GitHub organizations, filtered by technology and optional repository. +URL query parameters store the active filters (`org`, `tech`, `repository`, `status`, `sort`, `page`). + +### Auto-suggestions and dependencies + +Auto-suggestions are enabled for all three primary filter inputs: +- **Organization**: Debounced queries hit `GET /api/suggestions/organizations`, combining a curated set of popular open-source organizations with live results from GitHub's `/search/users?q=${query}+type:org` endpoint. +- **Technology**: `GET /api/suggestions/technologies` returns suggestions matching curated popular technologies and the bundled GitHub Linguist language catalog. +- **Repository (optional)**: Dependent on **Organization**. The repository input is disabled until an organization is provided. When active, `GET /api/suggestions/repositories?org=${org}&query=${query}` searches public repositories within that organization via GitHub's `/search/repositories?q=org:${org}...` endpoint. Changing the organization automatically resets any previously entered repository to maintain filter consistency. + +### Query execution and discovery + +`GET /api/organizations/issues` executes issue discovery: +- Scopes issues with `is:issue is:public archived:false`. +- If an exact repository is specified, `repo:${org}/${repository}` narrows the search to that repository. Otherwise, `org:${org}` scopes to all public repositories in the organization. +- Languages use GitHub language qualifiers (`language:"${name}"`). Framework technologies without direct language mappings discover up to 20 recently updated matching repositories via repository topics before searching issues across those repositories. +- Results return mapped issue cards with repository name, title, status, labels, comments, author, and timestamps. Concurrency is bounded to at most three concurrent GitHub search requests. + ## Issue discovery `GET /api/search` validates and rate-limits requests before querying GitHub. Languages use GitHub language qualifiers; framework and ecosystem terms such as React, Next.js, Spring Boot, and Kubernetes use repository topics. diff --git a/src/app/api/organizations/issues/route.ts b/src/app/api/organizations/issues/route.ts new file mode 100644 index 0000000..68fa4ed --- /dev/null +++ b/src/app/api/organizations/issues/route.ts @@ -0,0 +1,42 @@ +import { NextResponse } from "next/server"; +import { isRateLimitError } from "@/lib/github"; +import { + readOrganizationFilters, + validateOrganizationFilters, +} from "@/features/organizations/filters"; +import { searchOrganizationIssues } from "@/features/organizations/server/search"; + +export async function GET(request: Request) { + const { searchParams } = new URL(request.url); + const filters = readOrganizationFilters(searchParams); + const validationError = validateOrganizationFilters(filters); + + if (validationError) { + return NextResponse.json({ error: validationError }, { status: 400 }); + } + + try { + const result = await searchOrganizationIssues(filters); + return NextResponse.json(result); + } catch (error) { + if (isRateLimitError(error)) { + return NextResponse.json( + { + error: error.message, + rateLimit: true, + retryAfter: error.retryAfterSeconds, + }, + { status: 429 }, + ); + } + return NextResponse.json( + { + error: + error instanceof Error + ? error.message + : "Unable to search organization issues.", + }, + { status: 502 }, + ); + } +} diff --git a/src/app/api/suggestions/organizations/route.ts b/src/app/api/suggestions/organizations/route.ts new file mode 100644 index 0000000..cfcabea --- /dev/null +++ b/src/app/api/suggestions/organizations/route.ts @@ -0,0 +1,28 @@ +import { NextResponse } from "next/server"; +import { isRateLimitError } from "@/lib/github"; +import { getOrganizationSuggestions } from "@/features/organizations/server/suggestions"; + +export async function GET(request: Request) { + const { searchParams } = new URL(request.url); + const query = searchParams.get("query") ?? ""; + + try { + const organizations = await getOrganizationSuggestions(query); + return NextResponse.json({ organizations }); + } catch (error) { + if (isRateLimitError(error)) { + return NextResponse.json( + { + error: error.message, + rateLimit: true, + retryAfter: error.retryAfterSeconds, + }, + { status: 429 }, + ); + } + return NextResponse.json( + { error: "Failed to fetch organization suggestions." }, + { status: 500 }, + ); + } +} diff --git a/src/app/api/suggestions/repositories/route.ts b/src/app/api/suggestions/repositories/route.ts new file mode 100644 index 0000000..59cb09a --- /dev/null +++ b/src/app/api/suggestions/repositories/route.ts @@ -0,0 +1,36 @@ +import { NextResponse } from "next/server"; +import { isRateLimitError } from "@/lib/github"; +import { getRepositorySuggestions } from "@/features/organizations/server/suggestions"; + +export async function GET(request: Request) { + const { searchParams } = new URL(request.url); + const org = searchParams.get("org") ?? ""; + const query = searchParams.get("query") ?? ""; + + if (!org.trim()) { + return NextResponse.json( + { error: "Organization parameter is required." }, + { status: 400 }, + ); + } + + try { + const repositories = await getRepositorySuggestions(org, query); + return NextResponse.json({ repositories }); + } catch (error) { + if (isRateLimitError(error)) { + return NextResponse.json( + { + error: error.message, + rateLimit: true, + retryAfter: error.retryAfterSeconds, + }, + { status: 429 }, + ); + } + return NextResponse.json( + { error: "Failed to fetch repository suggestions." }, + { status: 500 }, + ); + } +} diff --git a/src/app/api/suggestions/technologies/route.ts b/src/app/api/suggestions/technologies/route.ts new file mode 100644 index 0000000..af14533 --- /dev/null +++ b/src/app/api/suggestions/technologies/route.ts @@ -0,0 +1,10 @@ +import { NextResponse } from "next/server"; +import { getTechnologySuggestions } from "@/features/organizations/server/suggestions"; + +export async function GET(request: Request) { + const { searchParams } = new URL(request.url); + const query = searchParams.get("query") ?? ""; + + const technologies = getTechnologySuggestions(query); + return NextResponse.json({ technologies }); +} diff --git a/src/app/organizations/page.tsx b/src/app/organizations/page.tsx new file mode 100644 index 0000000..0d176a4 --- /dev/null +++ b/src/app/organizations/page.tsx @@ -0,0 +1,23 @@ +import { Suspense } from "react"; +import type { Metadata } from "next"; +import { OrganizationDashboard } from "@/features/organizations/components/organization-dashboard"; + +export const metadata: Metadata = { + title: "Issues by Organization | OpenIssue.dev", + description: + "Search public GitHub issues across organizations by technology and repository, with auto-suggestions.", +}; + +export default function OrganizationsPage() { + return ( + + Loading organization dashboard… + + } + > + + + ); +} diff --git a/src/app/pull-requests/page.tsx b/src/app/pull-requests/page.tsx new file mode 100644 index 0000000..5506741 --- /dev/null +++ b/src/app/pull-requests/page.tsx @@ -0,0 +1,5 @@ +import { redirect } from "next/navigation"; + +export default function PullRequestsRedirect() { + redirect("/organizations"); +} diff --git a/src/components/dashboard-navigation.tsx b/src/components/dashboard-navigation.tsx new file mode 100644 index 0000000..0e3245b --- /dev/null +++ b/src/components/dashboard-navigation.tsx @@ -0,0 +1,39 @@ +import Link from "next/link"; +import { Building2, CircleDot } from "lucide-react"; +import { cn } from "@/lib/utils"; + +export function DashboardNavigation({ + current, +}: Readonly<{ current: "issues" | "organizations" | "pull-requests" }>) { + const activeTab = current === "pull-requests" ? "organizations" : current; + + return ( + + ); +} diff --git a/src/features/issues/components/issue-finder.tsx b/src/features/issues/components/issue-finder.tsx index f3c26bd..899a6c6 100644 --- a/src/features/issues/components/issue-finder.tsx +++ b/src/features/issues/components/issue-finder.tsx @@ -9,6 +9,7 @@ import { useState, } from "react"; import Image from "next/image"; +import { DashboardNavigation } from "@/components/dashboard-navigation"; import { Bookmark, Mail, Search, Sparkles, Trash2 } from "lucide-react"; import { ThemeToggle } from "@/components/theme-toggle"; import { AuthControls } from "@/components/auth-controls"; @@ -1065,11 +1066,6 @@ function getSelectedContentTab(activeTab: ContentTab, authenticated: boolean) { return authenticated ? activeTab : "results"; } -function getTokenStatus(data: SearchResponse | null) { - if (!data) return "unknown"; - return data.tokenConfigured ? "configured" : "not set"; -} - function getRetryHandler( errorSource: "search" | "loadMore" | null, search: () => void, @@ -1115,7 +1111,6 @@ function SearchOverview({ scope, responsiveness, data, - tokenStatus, }: Readonly<{ label: string; sort: string; @@ -1126,7 +1121,6 @@ function SearchOverview({ scope: string; responsiveness: string; data: SearchResponse | null; - tokenStatus: string; }>) { return ( @@ -1151,7 +1145,6 @@ function SearchOverview({ label="Raw GitHub matches" value={data ? compactNumber(data.totalCount) : "-"} /> - ); @@ -1747,7 +1740,6 @@ export function IssueFinder() { } } - const tokenStatus = getTokenStatus(data); const handleRetry = getRetryHandler( errorSource, () => void searchIssues(), @@ -1784,6 +1776,7 @@ export function IssueFinder() { +

Find active open-source issues by tech. @@ -1981,7 +1974,6 @@ export function IssueFinder() { scope={selectedScope.label} responsiveness={selectedResponsiveness.label} data={data} - tokenStatus={tokenStatus} />

diff --git a/src/features/issues/server/github-search.ts b/src/features/issues/server/github-search.ts index ee71462..ddd21a4 100644 --- a/src/features/issues/server/github-search.ts +++ b/src/features/issues/server/github-search.ts @@ -1,3 +1,7 @@ +import { githubFetch } from "@/lib/github"; +import { getRepositoryResponsiveness, getCommunityProfile } from "@/features/repositories/server/github-repository"; +export { RateLimitError, isRateLimitError } from "@/lib/github"; +export { getRepositoryResponsiveness } from "@/features/repositories/server/github-repository"; import { CONTRIBUTION_TYPE_FILTERS, EXPERIENCE_FILTERS, @@ -24,10 +28,7 @@ import { import { scoreRepositoryHealth } from "@/features/issues/lib/repository-health"; import { getResponsivenessBoost, - scoreRepositoryResponsiveness, unknownRepositoryResponsiveness, - type ResponsivenessIssue, - type ResponsivenessPullRequest, } from "@/features/issues/lib/repository-responsiveness"; import type { GitHubIssue, @@ -50,66 +51,6 @@ const REPO_ISSUE_BATCH_SIZE = 10; const RESPONSIVENESS_REPOSITORY_LIMIT = 12; const COMMUNITY_PROFILE_REPOSITORY_LIMIT = 12; -export class RateLimitError extends Error { - retryAfterSeconds: number | null; - - constructor(message: string, retryAfterSeconds: number | null = null) { - super(message); - this.name = "RateLimitError"; - this.retryAfterSeconds = retryAfterSeconds; - } -} - -export function isRateLimitError(error: unknown): error is RateLimitError { - return error instanceof RateLimitError; -} - -type GitHubCommunityProfileResponse = { - health_percentage: number; - files: Partial< - Record< - | "readme" - | "contributing" - | "license" - | "code_of_conduct" - | "issue_template" - | "pull_request_template", - { html_url?: string | null } | null - > - >; -}; - -type GitHubResponsivenessResponse = { - data?: { - repository?: { - issues: { nodes: ResponsivenessIssue[] }; - pullRequests: { nodes: ResponsivenessPullRequest[] }; - } | null; - }; - errors?: Array<{ message: string }>; -}; - -const RESPONSIVENESS_QUERY = ` - query RepositoryResponsiveness($owner: String!, $name: String!, $since: DateTime!) { - repository(owner: $owner, name: $name) { - issues(first: 20, orderBy: { field: CREATED_AT, direction: DESC }, filterBy: { since: $since }) { - nodes { - author { login } - closedAt - createdAt - labels(first: 10) { nodes { name } } - comments(first: 20) { - nodes { author { login } authorAssociation createdAt } - } - } - } - pullRequests(first: 20, orderBy: { field: CREATED_AT, direction: DESC }) { - nodes { authorAssociation createdAt mergedAt } - } - } - } -`; - function normalize(value: string | null) { return (value ?? "").trim().toLowerCase(); } @@ -391,74 +332,6 @@ function summarizeEnrichment(issues: Issue[], signal: keyof IssueEnrichment) { return "partial" as const; } -async function githubFetch(url: string, token?: string, revalidate = 60) { - const response = await fetch(url, { - headers: { - Accept: "application/vnd.github+json", - "X-GitHub-Api-Version": "2022-11-28", - ...(token ? { Authorization: `Bearer ${token}` } : {}), - }, - next: { revalidate }, - }); - - if (!response.ok) { - const body = await response.text(); - const retryAfterSeconds = computeRetryAfterSeconds(response.headers); - - if ( - (response.status === 403 || response.status === 429) && - isRateLimitResponse(body) - ) { - throw new RateLimitError( - "GitHub API rate limit exceeded. Please wait a few minutes and try again.", - retryAfterSeconds, - ); - } - - throw new Error(`GitHub API error ${response.status}: ${body}`); - } - - return { - data: (await response.json()) as T, - rateLimitRemaining: response.headers.get("x-ratelimit-remaining"), - }; -} - -function isRateLimitResponse(body: string): boolean { - const lower = body.toLowerCase(); - return ( - lower.includes("rate limit") || - lower.includes("rate_limit") || - lower.includes("api rate limit exceeded") || - lower.includes("secondary rate limit") - ); -} - -// GitHub's primary rate-limit responses commonly omit `retry-after` and -// instead provide `x-ratelimit-reset`, a Unix timestamp (seconds) for when -// the limit resets. Fall back to computing the delay from that header so we -// don't under-report the wait time with a default cooldown. -function computeRetryAfterSeconds(headers: Headers): number | null { - const retryAfter = headers.get("retry-after"); - if (retryAfter) { - const parsed = Number.parseInt(retryAfter, 10); - if (!Number.isNaN(parsed)) { - return parsed; - } - } - - const resetHeader = headers.get("x-ratelimit-reset"); - if (resetHeader) { - const resetEpochSeconds = Number.parseInt(resetHeader, 10); - if (!Number.isNaN(resetEpochSeconds)) { - const nowEpochSeconds = Math.floor(Date.now() / 1000); - return Math.max(0, resetEpochSeconds - nowEpochSeconds); - } - } - - return null; -} - async function buildSearchScope(tech: string, token?: string) { const repoTopicQuery = buildRepoTopicQuery(tech); const queryParts = ["is:issue", "is:open", "archived:false"]; @@ -504,89 +377,6 @@ function getSearchTotalCount( return searchResults[0]?.data.total_count ?? 0; } -export async function getRepositoryResponsiveness( - fullName: string, - token = process.env.GITHUB_TOKEN, -) { - if (!token) { - return unknownRepositoryResponsiveness( - "GitHub token required for responsiveness analysis", - ); - } - - const [owner, name] = fullName.split("/"); - if (!owner || !name) return unknownRepositoryResponsiveness(); - - const sinceDate = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000); - sinceDate.setUTCHours(Math.floor(sinceDate.getUTCHours() / 6) * 6, 0, 0, 0); - const since = sinceDate.toISOString(); - const response = await fetch("https://api.github.com/graphql", { - method: "POST", - headers: { - Accept: "application/vnd.github+json", - Authorization: `Bearer ${token}`, - "Content-Type": "application/json", - "X-GitHub-Api-Version": "2022-11-28", - }, - body: JSON.stringify({ - query: RESPONSIVENESS_QUERY, - variables: { owner, name, since }, - }), - next: { revalidate: 21600 }, - }); - - if (!response.ok) { - const body = await response.text(); - const retryAfterSeconds = computeRetryAfterSeconds(response.headers); - - if ( - (response.status === 403 || response.status === 429) && - isRateLimitResponse(body) - ) { - throw new RateLimitError( - "GitHub API rate limit exceeded. Please wait a few minutes and try again.", - retryAfterSeconds, - ); - } - - throw new Error(`GitHub GraphQL error ${response.status}`); - } - const payload = (await response.json()) as GitHubResponsivenessResponse; - const repository = payload.data?.repository; - - if (!repository || payload.errors?.length) { - throw new Error( - payload.errors?.[0]?.message ?? "Repository analytics unavailable", - ); - } - - return scoreRepositoryResponsiveness( - repository.issues.nodes, - repository.pullRequests.nodes, - ); -} - -async function getCommunityProfile(fullName: string, token?: string) { - const result = await githubFetch( - `https://api.github.com/repos/${fullName}/community/profile`, - token, - 21600, - ); - const files = result.data.files ?? {}; - - return { - healthPercentage: result.data.health_percentage, - documentation: { - readme: files.readme?.html_url ?? null, - contributing: files.contributing?.html_url ?? null, - license: files.license?.html_url ?? null, - codeOfConduct: files.code_of_conduct?.html_url ?? null, - issueTemplate: files.issue_template?.html_url ?? null, - pullRequestTemplate: files.pull_request_template?.html_url ?? null, - }, - }; -} - export async function searchGitHubRepositories( query: string, ): Promise { diff --git a/src/features/organizations/client.ts b/src/features/organizations/client.ts new file mode 100644 index 0000000..e111dbe --- /dev/null +++ b/src/features/organizations/client.ts @@ -0,0 +1,29 @@ +export class OrganizationClientError extends Error { + retryAfter: number | null; + + constructor(message: string, retryAfter: number | null = null) { + super(message); + this.name = "OrganizationClientError"; + this.retryAfter = retryAfter; + } +} + +export async function fetchOrganizationData( + url: string, + options?: RequestInit, +): Promise { + const response = await fetch(url, options); + if (!response.ok) { + const retryHeader = response.headers.get("Retry-After"); + const retryAfter = retryHeader ? Number.parseInt(retryHeader, 10) : null; + let message = `Request failed (${response.status})`; + try { + const body = (await response.json()) as { error?: string }; + if (body.error) message = body.error; + } catch { + // Use fallback + } + throw new OrganizationClientError(message, retryAfter); + } + return response.json() as Promise; +} diff --git a/src/features/organizations/components/autocomplete-input.tsx b/src/features/organizations/components/autocomplete-input.tsx new file mode 100644 index 0000000..3ad3b17 --- /dev/null +++ b/src/features/organizations/components/autocomplete-input.tsx @@ -0,0 +1,261 @@ +"use client"; + +import { + type ChangeEvent, + type KeyboardEvent, + type ReactNode, + useCallback, + useEffect, + useId, + useRef, + useState, +} from "react"; +import { Loader2, X } from "lucide-react"; +import { Input } from "@/components/ui/input"; +import { cn } from "@/lib/utils"; + +export type AutocompleteInputProps = { + value: string; + onChange: (value: string) => void; + fetchSuggestions: (query: string) => Promise | T[]; + getSuggestionValue: (item: T) => string; + getSuggestionKey: (item: T) => string; + renderSuggestion: (item: T, isHighlighted: boolean) => ReactNode; + placeholder?: string; + required?: boolean; + disabled?: boolean; + maxLength?: number; + id?: string; + name?: string; + ariaLabel?: string; + icon?: ReactNode; + emptyMessage?: string; + debounceMs?: number; + onSelect?: (item: T) => void; + className?: string; + inputClassName?: string; +}; + +export function AutocompleteInput({ + value, + onChange, + fetchSuggestions, + getSuggestionValue, + getSuggestionKey, + renderSuggestion, + placeholder, + required, + disabled, + maxLength, + id, + name, + ariaLabel, + icon, + emptyMessage = "No suggestions found", + debounceMs = 250, + onSelect, + className, + inputClassName, +}: Readonly>) { + const [isOpen, setIsOpen] = useState(false); + const [suggestions, setSuggestions] = useState([]); + const [highlightedIndex, setHighlightedIndex] = useState(-1); + const [loading, setLoading] = useState(false); + const containerRef = useRef(null); + const inputRef = useRef(null); + const listboxId = useId(); + + const load = useCallback( + async (query: string) => { + setLoading(true); + try { + const results = await fetchSuggestions(query); + setSuggestions(results); + setHighlightedIndex(-1); + } catch { + setSuggestions([]); + } finally { + setLoading(false); + } + }, + [fetchSuggestions], + ); + + useEffect(() => { + if (!isOpen || disabled) return; + const timer = setTimeout(() => { + void load(value); + }, debounceMs); + + return () => clearTimeout(timer); + }, [value, isOpen, disabled, load, debounceMs]); + + useEffect(() => { + function handleClickOutside(event: MouseEvent) { + if ( + containerRef.current && + !containerRef.current.contains(event.target as Node) + ) { + setIsOpen(false); + } + } + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, []); + + function handleFocus() { + if (disabled) return; + setIsOpen(true); + void load(value); + } + + function handleChange(e: ChangeEvent) { + onChange(e.target.value); + if (!isOpen) setIsOpen(true); + } + + function handleSelect(item: T) { + const nextVal = getSuggestionValue(item); + onChange(nextVal); + onSelect?.(item); + setIsOpen(false); + inputRef.current?.focus(); + } + + function handleKeyDown(e: KeyboardEvent) { + if (!isOpen) { + if (e.key === "ArrowDown" || e.key === "ArrowUp") { + setIsOpen(true); + void load(value); + e.preventDefault(); + } + return; + } + + if (e.key === "ArrowDown") { + e.preventDefault(); + setHighlightedIndex((prev) => + prev < suggestions.length - 1 ? prev + 1 : 0, + ); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + setHighlightedIndex((prev) => + prev > 0 ? prev - 1 : suggestions.length - 1, + ); + } else if (e.key === "Enter") { + if (highlightedIndex >= 0 && highlightedIndex < suggestions.length) { + e.preventDefault(); + handleSelect(suggestions[highlightedIndex]); + } + } else if (e.key === "Escape") { + e.preventDefault(); + setIsOpen(false); + } else if (e.key === "Tab") { + setIsOpen(false); + } + } + + function handleClear() { + onChange(""); + setSuggestions([]); + setIsOpen(false); + inputRef.current?.focus(); + } + + function renderDropdownContent() { + if (loading && suggestions.length === 0) { + return ( +
  • + Loading suggestions… +
  • + ); + } + + if (suggestions.length === 0) { + return ( +
  • + {emptyMessage} +
  • + ); + } + + return suggestions.map((item, index) => { + const isHighlighted = index === highlightedIndex; + return ( +
  • + +
  • + ); + }); + } + + return ( +
    +
    + {icon ? ( + + {icon} + + ) : null} + +
    + {loading && } + {value && !disabled && ( + + )} +
    +
    + + {isOpen && !disabled && ( +
      + {renderDropdownContent()} +
    + )} +
    + ); +} diff --git a/src/features/organizations/components/organization-dashboard.tsx b/src/features/organizations/components/organization-dashboard.tsx new file mode 100644 index 0000000..6893245 --- /dev/null +++ b/src/features/organizations/components/organization-dashboard.tsx @@ -0,0 +1,511 @@ +"use client"; + +import { useEffect, useRef, useState, type SubmitEvent } from "react"; +import Image from "next/image"; +import { useRouter, useSearchParams } from "next/navigation"; +import { + Building2, + ChevronLeft, + ChevronRight, + GitFork, + Info, + Search, + Star, +} from "lucide-react"; +import { DashboardNavigation } from "@/components/dashboard-navigation"; +import { ThemeToggle } from "@/components/theme-toggle"; +import { AuthControls } from "@/components/auth-controls"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { LoadingResults } from "@/features/issues/components/loading-results"; +import { AutocompleteInput } from "./autocomplete-input"; +import { OrganizationIssueCard } from "./organization-issue-card"; +import { fetchOrganizationData, OrganizationClientError } from "../client"; +import { + DEFAULT_ORGANIZATION_FILTERS, + organizationSearchParams, + readOrganizationFilters, + validateOrganizationFilters, +} from "../filters"; +import type { + OrganizationIssueFilters, + OrganizationIssueSearchResponse, + OrganizationSuggestion, + OrgRepositorySuggestion, + TechnologySuggestion, +} from "../types"; + +const STATUS_OPTIONS: ReadonlyArray<{ + value: OrganizationIssueFilters["status"]; + label: string; +}> = [ + { value: "open", label: "Open" }, + { value: "closed", label: "Closed" }, + { value: "all", label: "All statuses" }, +]; + +const SORT_OPTIONS: ReadonlyArray<{ + value: OrganizationIssueFilters["sort"]; + label: string; +}> = [ + { value: "updated", label: "Recently updated" }, + { value: "created", label: "Newest" }, + { value: "comments", label: "Most comments" }, +]; + +function getSearchButtonLabel( + loading: boolean, + cooldown: number | null, +): string { + if (loading) return "Searching…"; + if (cooldown !== null) return "Cooldown…"; + return "Search"; +} + +function SearchSession({ query }: Readonly<{ query: string }>) { + const router = useRouter(); + const [filters, setFilters] = useState(() => + query + ? readOrganizationFilters(new URLSearchParams(query)) + : DEFAULT_ORGANIZATION_FILTERS, + ); + const [data, setData] = useState(null); + const [error, setError] = useState(""); + const [loading, setLoading] = useState(!!query); + const [attempt, setAttempt] = useState(0); + const [cooldown, setCooldown] = useState(null); + const controller = useRef(null); + + useEffect(() => { + if (cooldown === null) return; + const timer = setTimeout(() => setCooldown(null), cooldown * 1000); + return () => clearTimeout(timer); + }, [cooldown]); + + useEffect(() => { + if (!query) return; + const abort = new AbortController(); + controller.current = abort; + + async function run() { + const parsedFilters = readOrganizationFilters(new URLSearchParams(query)); + const validation = validateOrganizationFilters(parsedFilters); + setError(validation ?? ""); + if (validation) { + setLoading(false); + return; + } + + setLoading(true); + try { + const result = + await fetchOrganizationData( + `/api/organizations/issues?${query}`, + { signal: abort.signal }, + ); + if (abort.signal.aborted) return; + setData(result); + setLoading(false); + } catch (err) { + if (abort.signal.aborted) return; + setError(err instanceof Error ? err.message : "Search failed."); + if (err instanceof OrganizationClientError) { + setCooldown(err.retryAfter); + } + setLoading(false); + } + } + + void run(); + return () => abort.abort(); + }, [query, attempt]); + + function updateFilter( + key: K, + value: OrganizationIssueFilters[K], + ) { + controller.current?.abort(); + setLoading(false); + setFilters((current) => { + const next = { ...current, [key]: value, page: 1 }; + // If organization changed, clear repository if it doesn't belong to the new org + if (key === "org" && current.repository) { + next.repository = ""; + } + return next; + }); + } + + function navigate(next: OrganizationIssueFilters) { + const validation = validateOrganizationFilters(next); + if (validation) { + setError(validation); + return; + } + const params = organizationSearchParams(next).toString(); + if (params === query) setAttempt((value) => value + 1); + else router.push(`/organizations?${params}`, { scroll: false }); + } + + function submit(event: SubmitEvent) { + event.preventDefault(); + navigate({ ...filters, page: 1 }); + } + + async function fetchOrgSuggestions(q: string) { + const res = await fetchOrganizationData<{ + organizations: OrganizationSuggestion[]; + }>(`/api/suggestions/organizations?query=${encodeURIComponent(q)}`); + return res.organizations; + } + + async function fetchTechSuggestions(q: string) { + const res = await fetchOrganizationData<{ + technologies: TechnologySuggestion[]; + }>(`/api/suggestions/technologies?query=${encodeURIComponent(q)}`); + return res.technologies; + } + + async function fetchRepoSuggestions(q: string) { + if (!filters.org.trim()) return []; + const res = await fetchOrganizationData<{ + repositories: OrgRepositorySuggestion[]; + }>( + `/api/suggestions/repositories?org=${encodeURIComponent(filters.org)}&query=${encodeURIComponent(q)}`, + ); + return res.repositories; + } + + return ( + <> +
    +
    + + + id="org-input" + ariaLabel="Organization" + icon={} + value={filters.org} + onChange={(val) => updateFilter("org", val)} + fetchSuggestions={fetchOrgSuggestions} + getSuggestionValue={(item) => item.login} + getSuggestionKey={(item) => item.login} + placeholder="Organization (e.g. vercel)" + required + maxLength={39} + emptyMessage="No organizations found" + renderSuggestion={(item) => ( +
    + {item.avatarUrl ? ( + {item.login} + ) : ( + + )} +
    + {item.login} + {item.description && ( + + {item.description} + + )} +
    +
    + )} + /> +
    + +
    + + + id="tech-input" + ariaLabel="Technology" + icon={} + value={filters.tech} + onChange={(val) => updateFilter("tech", val)} + fetchSuggestions={fetchTechSuggestions} + getSuggestionValue={(item) => item.name} + getSuggestionKey={(item) => item.name} + placeholder="Technology (e.g. TypeScript)" + required + maxLength={80} + emptyMessage="No technologies found" + renderSuggestion={(item) => ( +
    + {item.name} + + {item.type} + +
    + )} + /> +
    + +
    + + + id="repo-input" + ariaLabel="Repository (optional)" + icon={} + value={filters.repository} + onChange={(val) => updateFilter("repository", val)} + fetchSuggestions={fetchRepoSuggestions} + getSuggestionValue={(item) => item.name} + getSuggestionKey={(item) => item.fullName} + disabled={!filters.org.trim()} + placeholder={ + filters.org.trim() + ? `Repository in ${filters.org} (optional)` + : "Select an organization first" + } + maxLength={140} + emptyMessage="No repositories found in this organization" + renderSuggestion={(item) => ( +
    +
    + {item.name} + {item.description && ( + + {item.description} + + )} +
    + + + {item.stars} + +
    + )} + /> +
    + +
    + + +
    + +
    + + +
    + + +
    + +

    + Search open-source issues across an organization's repositories by technology. + Auto-suggestions are enabled for organization, technology, and repositories. +

    + + {error && ( + + + {error} + {cooldown !== null && ( + + Rate limited by GitHub. Retry enabled in {cooldown}s. + + )} + + + )} + + {data?.notices && data.notices.length > 0 && ( +
    + {data.notices.map((notice) => ( +
    + + {notice} +
    + ))} +
    + )} + + {loading && } + + {!loading && data && ( +
    +
    + + Found {data.totalCount}{" "} + issues in {filters.org} + + Page {data.page} +
    + + {data.issues.length === 0 ? ( + + +

    No issues found.

    +

    + Try broadening your search or choosing a different technology or repository. +

    +
    +
    + ) : ( +
    + {data.issues.map((issue) => ( + + ))} +
    + )} + +
    + + + Page {data.page} + + +
    +
    + )} + + {!query && ( + + + +

    + Explore open-source issues by organization +

    +

    + Select or type an organization and technology to discover issues ready for contribution. + You can also narrow the search to a specific repository. +

    +
    +
    + )} + + ); +} + +export function OrganizationDashboard() { + const searchParams = useSearchParams(); + const query = searchParams.toString(); + + return ( +
    +
    +
    +
    +
    + +
    +
    + + +
    +
    +
    +

    + Find issues by organization. +

    +

    + Discover issues across top open-source organizations filtered by your preferred technology. +

    +
    +
    + +
    + +
    +
    +
    + ); +} diff --git a/src/features/organizations/components/organization-issue-card.tsx b/src/features/organizations/components/organization-issue-card.tsx new file mode 100644 index 0000000..201fc69 --- /dev/null +++ b/src/features/organizations/components/organization-issue-card.tsx @@ -0,0 +1,86 @@ +"use client"; + +import Image from "next/image"; +import { CircleDot, MessageCircle, ExternalLink } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { compactNumber, relativeDate } from "@/features/issues/lib/format"; +import type { OrganizationIssue } from "../types"; + +export function OrganizationIssueCard({ + issue, +}: Readonly<{ issue: OrganizationIssue }>) { + const isOpen = issue.status === "open"; + + return ( + + +
    + + {issue.repository} #{issue.number} + +
    + + + {isOpen ? "Open" : "Closed"} + +
    +
    + + + {issue.title} + + + +
    + + {issue.labels.length > 0 && ( +
    + {issue.labels.map((label) => ( + + {label.name} + + ))} +
    + )} +
    +
    + {issue.authorAvatarUrl ? ( + {issue.author} + ) : null} + {issue.author} +
    +
    + + + {compactNumber(issue.comments)} + + Updated {relativeDate(issue.updatedAt)} +
    +
    +
    +
    + ); +} diff --git a/src/features/organizations/data/languages.json b/src/features/organizations/data/languages.json new file mode 100644 index 0000000..3076ae8 --- /dev/null +++ b/src/features/organizations/data/languages.json @@ -0,0 +1,1271 @@ +{ + "1c enterprise": "1C Enterprise", + "2-dimensional array": "2-Dimensional Array", + "4d": "4D", + "abap": "ABAP", + "abap cds": "ABAP CDS", + "abl": "OpenEdge ABL", + "abnf": "ABNF", + "abuild": "Alpine Abuild", + "acfm": "Adobe Font Metrics", + "ackrc": "Option List", + "aconf": "ApacheConf", + "actionscript": "ActionScript", + "actionscript 3": "ActionScript", + "actionscript3": "ActionScript", + "ad block": "Adblock Filter List", + "ad block filters": "Adblock Filter List", + "ada": "Ada", + "ada2005": "Ada", + "ada95": "Ada", + "adb": "Adblock Filter List", + "adblock": "Adblock Filter List", + "adblock filter list": "Adblock Filter List", + "adobe composite font metrics": "Adobe Font Metrics", + "adobe font metrics": "Adobe Font Metrics", + "adobe multiple font metrics": "Adobe Font Metrics", + "advpl": "xBase", + "afdko": "OpenType Feature File", + "agda": "Agda", + "ags": "AGS Script", + "ags script": "AGS Script", + "ahk": "AutoHotkey", + "aidl": "AIDL", + "aiken": "Aiken", + "al": "AL", + "aleo": "Aleo", + "algol": "ALGOL", + "alloy": "Alloy", + "alpine abuild": "Alpine Abuild", + "altium": "Altium Designer", + "altium designer": "Altium Designer", + "amfm": "Adobe Font Metrics", + "ampl": "AMPL", + "amusewiki": "Muse", + "angelscript": "AngelScript", + "answer set programming": "Answer Set Programming", + "ant build system": "Ant Build System", + "antlers": "Antlers", + "antlr": "ANTLR", + "apache": "ApacheConf", + "apacheconf": "ApacheConf", + "apex": "Apex", + "api blueprint": "API Blueprint", + "apkbuild": "Alpine Abuild", + "apl": "APL", + "apollo guidance computer": "Apollo Guidance Computer", + "apples": "AppleScript", + "applescript": "AppleScript", + "arc": "Arc", + "arexx": "REXX", + "arkts": "ArkTS", + "as3": "ActionScript", + "ascii stl": "STL", + "asciidoc": "AsciiDoc", + "asl": "ASL", + "asm": "Assembly", + "asn.1": "ASN.1", + "asp": "Classic ASP", + "asp.net": "ASP.NET", + "aspectj": "AspectJ", + "aspx": "ASP.NET", + "aspx-vb": "ASP.NET", + "assembly": "Assembly", + "astro": "Astro", + "asymptote": "Asymptote", + "ats": "ATS", + "ats2": "ATS", + "au3": "AutoIt", + "augeas": "Augeas", + "autoconf": "M4Sugar", + "autohotkey": "AutoHotkey", + "autoit": "AutoIt", + "autoit3": "AutoIt", + "autoitscript": "AutoIt", + "avro idl": "Avro IDL", + "awk": "Awk", + "b": "B", + "b (formal method)": "B (Formal Method)", + "b3d": "BlitzBasic", + "b4x": "B4X", + "ballerina": "Ballerina", + "baml": "BAML", + "bash": "Shell", + "bash session": "ShellSession", + "basic": "BASIC", + "basic for android": "B4X", + "bat": "Batchfile", + "batch": "Batchfile", + "batchfile": "Batchfile", + "bazel": "Starlark", + "bbcode": "BBCode", + "be": "Berry", + "beef": "Beef", + "befunge": "Befunge", + "berry": "Berry", + "bh": "Bluespec BH", + "bibtex": "BibTeX", + "bibtex style": "BibTeX Style", + "bicep": "Bicep", + "bikeshed": "Bikeshed", + "bird": "BIRD2", + "bird2": "BIRD2", + "bird3": "BIRD2", + "bison": "Bison", + "bitbake": "BitBake", + "blade": "Blade", + "blitz3d": "BlitzBasic", + "blitzbasic": "BlitzBasic", + "blitzmax": "BlitzMax", + "blitzplus": "BlitzBasic", + "blp": "Blueprint", + "blueprint": "Blueprint", + "bluespec": "Bluespec", + "bluespec bh": "Bluespec BH", + "bluespec bsv": "Bluespec", + "bluespec classic": "Bluespec BH", + "bmax": "BlitzMax", + "boo": "Boo", + "boogie": "Boogie", + "bplus": "BlitzBasic", + "bqn": "BQN", + "brainfuck": "Brainfuck", + "brighterscript": "BrighterScript", + "brightscript": "Brightscript", + "bro": "Zeek", + "browserslist": "Browserslist", + "bru": "Bru", + "bsdmake": "Makefile", + "bsv": "Bluespec", + "buildstream": "BuildStream", + "byond": "DM", + "bzl": "Starlark", + "c": "C", + "c-objdump": "C-ObjDump", + "c#": "C#", + "c++": "C++", + "c++-objdump": "Cpp-ObjDump", + "c2hs": "C2hs Haskell", + "c2hs haskell": "C2hs Haskell", + "c3": "C3", + "cabal": "Cabal Config", + "cabal config": "Cabal Config", + "caddy": "Caddyfile", + "caddyfile": "Caddyfile", + "cadence": "Cadence", + "cairo": "Cairo", + "cairo zero": "Cairo Zero", + "cake": "C#", + "cakescript": "C#", + "cameligo": "CameLIGO", + "cangjie": "Cangjie", + "cap cds": "CAP CDS", + "cap'n proto": "Cap'n Proto", + "carbon": "Carbon", + "carto": "CartoCSS", + "cartocss": "CartoCSS", + "cask": "Emacs Lisp", + "cds": "CAP CDS", + "ceylon": "Ceylon", + "cfc": "ColdFusion CFC", + "cfm": "ColdFusion", + "cfml": "ColdFusion", + "chapel": "Chapel", + "charity": "Charity", + "checksum": "Checksums", + "checksums": "Checksums", + "chpl": "Chapel", + "chuck": "ChucK", + "cil": "CIL", + "circom": "Circom", + "cirru": "Cirru", + "clarion": "Clarion", + "clarity": "Clarity", + "classic asp": "Classic ASP", + "classic qbasic": "QuickBASIC", + "classic quickbasic": "QuickBASIC", + "classic visual basic": "Visual Basic 6.0", + "clean": "Clean", + "click": "Click", + "clipper": "xBase", + "clips": "CLIPS", + "clojure": "Clojure", + "closure templates": "Closure Templates", + "cloud firestore security rules": "Cloud Firestore Security Rules", + "clue": "Clue", + "cmake": "CMake", + "cobol": "COBOL", + "coccinelle": "SmPL", + "codeowners": "CODEOWNERS", + "codeql": "CodeQL", + "coffee": "CoffeeScript", + "coffee-script": "CoffeeScript", + "coffeescript": "CoffeeScript", + "coldfusion": "ColdFusion", + "coldfusion cfc": "ColdFusion CFC", + "coldfusion html": "ColdFusion", + "collada": "COLLADA", + "commit": "Git Commit", + "common lisp": "Common Lisp", + "common workflow language": "Common Workflow Language", + "component pascal": "Component Pascal", + "conf": "INI", + "conll": "CoNLL-U", + "conll-u": "CoNLL-U", + "conll-x": "CoNLL-U", + "console": "ShellSession", + "containerfile": "Dockerfile", + "cooklang": "Cooklang", + "cool": "Cool", + "coq": "Rocq Prover", + "cperl": "Perl", + "cpp": "C++", + "cpp-objdump": "Cpp-ObjDump", + "cql": "CQL", + "creole": "Creole", + "cron": "crontab", + "cron table": "crontab", + "crontab": "crontab", + "crystal": "Crystal", + "csharp": "C#", + "cson": "CSON", + "csound": "Csound", + "csound document": "Csound Document", + "csound score": "Csound Score", + "csound-csd": "Csound Document", + "csound-orc": "Csound", + "csound-sco": "Csound Score", + "css": "CSS", + "csv": "CSV", + "cucumber": "Gherkin", + "cuda": "Cuda", + "cue": "CUE", + "cue sheet": "Cue Sheet", + "curl config": "cURL Config", + "curlrc": "cURL Config", + "curry": "Curry", + "cweb": "CWeb", + "cwl": "Common Workflow Language", + "cycript": "Cycript", + "cylc": "Cylc", + "cypher": "Cypher", + "cython": "Cython", + "d": "D", + "d-objdump": "D-ObjDump", + "d2": "D2", + "d2lang": "D2", + "dafny": "Dafny", + "darcs patch": "Darcs Patch", + "dart": "Dart", + "daslang": "Daslang", + "dataweave": "DataWeave", + "dcl": "DIGITAL Command Language", + "debian package control file": "Debian Package Control File", + "delphi": "Pascal", + "denizenscript": "DenizenScript", + "desktop": "desktop", + "dhall": "Dhall", + "diff": "Diff", + "digital command language": "DIGITAL Command Language", + "dircolors": "dircolors", + "directx 3d file": "DirectX 3D File", + "django": "Jinja", + "dlang": "D", + "dm": "DM", + "dns zone": "DNS Zone", + "dockerfile": "Dockerfile", + "dogescript": "Dogescript", + "dosbatch": "Batchfile", + "dosini": "INI", + "dotenv": "Dotenv", + "dpatch": "Darcs Patch", + "dtrace": "DTrace", + "dtrace-script": "DTrace", + "dune": "Dune", + "dylan": "Dylan", + "e": "E", + "e-mail": "E-mail", + "eagle": "Eagle", + "earthfile": "Earthly", + "earthly": "Earthly", + "eask": "Emacs Lisp", + "easybuild": "Easybuild", + "ebnf": "EBNF", + "ec": "eC", + "ecere projects": "Ecere Projects", + "ecl": "ECL", + "eclipse": "ECLiPSe", + "ecmarkdown": "Ecmarkup", + "ecmarkup": "Ecmarkup", + "ecr": "HTML+ECR", + "edge": "Edge", + "edgeql": "EdgeQL", + "editor-config": "EditorConfig", + "editorconfig": "EditorConfig", + "edje data collection": "Edje Data Collection", + "edn": "edn", + "eeschema schematic": "KiCad Schematic", + "eex": "HTML+EEX", + "eiffel": "Eiffel", + "ejs": "EJS", + "electronic business card": "vCard", + "elisp": "Emacs Lisp", + "elixir": "Elixir", + "elm": "Elm", + "elvish": "Elvish", + "elvish transcript": "Elvish Transcript", + "emacs": "Emacs Lisp", + "emacs lisp": "Emacs Lisp", + "emacs muse": "Muse", + "email": "E-mail", + "emberscript": "EmberScript", + "eml": "E-mail", + "envrc": "Shell", + "eq": "EQ", + "erb": "HTML+ERB", + "erlang": "Erlang", + "esdl": "EdgeQL", + "euphoria": "Euphoria", + "f*": "F*", + "f#": "F#", + "factor": "Factor", + "fancy": "Fancy", + "fantom": "Fantom", + "faust": "Faust", + "fb": "FreeBASIC", + "fennel": "Fennel", + "figfont": "FIGlet Font", + "figlet font": "FIGlet Font", + "filebench wml": "Filebench WML", + "filterscript": "Filterscript", + "firrtl": "FIRRTL", + "fish": "fish", + "flatbuffers": "FlatBuffers", + "flex": "Lex", + "flix": "Flix", + "fluent": "Fluent", + "flux": "FLUX", + "formatted": "Formatted", + "forth": "Forth", + "fortran": "Fortran", + "fortran free form": "Fortran Free Form", + "foxpro": "xBase", + "fpp": "FPP", + "freebasic": "FreeBASIC", + "freemarker": "FreeMarker", + "frege": "Frege", + "fsharp": "F#", + "fstar": "F*", + "ftl": "FreeMarker", + "fundamental": "Text", + "futhark": "Futhark", + "g-code": "G-code", + "game maker language": "Game Maker Language", + "gaml": "GAML", + "gams": "GAMS", + "gap": "GAP", + "gas": "Unix Assembly", + "gcc machine description": "GCC Machine Description", + "gdb": "GDB", + "gdscript": "GDScript", + "gdshader": "GDShader", + "gedcom": "GEDCOM", + "gemfile.lock": "Gemfile.lock", + "gemini": "Gemini", + "gemtext": "Gemini", + "genero 4gl": "Genero 4gl", + "genero per": "Genero per", + "genie": "Genie", + "genshi": "Genshi", + "gentoo ebuild": "Gentoo Ebuild", + "gentoo eclass": "Gentoo Eclass", + "geojson": "JSON", + "gerber image": "Gerber Image", + "gettext catalog": "Gettext Catalog", + "gf": "Grammatical Framework", + "gherkin": "Gherkin", + "git attributes": "Git Attributes", + "git blame ignore revs": "Git Revision List", + "git commit": "Git Commit", + "git config": "Git Config", + "git revision list": "Git Revision List", + "git-ignore": "Ignore List", + "gitattributes": "Git Attributes", + "gitconfig": "Git Config", + "gitignore": "Ignore List", + "gitmodules": "Git Config", + "gjs": "Glimmer JS", + "gleam": "Gleam", + "glimmer js": "Glimmer JS", + "glimmer ts": "Glimmer TS", + "glsl": "GLSL", + "glyph": "Glyph", + "glyph bitmap distribution format": "Glyph Bitmap Distribution Format", + "gn": "GN", + "gno": "Gno", + "gnolang": "Gno", + "gnu asm": "Unix Assembly", + "gnuplot": "Gnuplot", + "go": "Go", + "go checksums": "Go Checksums", + "go mod": "Go Module", + "go module": "Go Module", + "go sum": "Go Checksums", + "go template": "Go Template", + "go work": "Go Workspace", + "go work sum": "Go Checksums", + "go workspace": "Go Workspace", + "go.mod": "Go Module", + "go.sum": "Go Checksums", + "go.work": "Go Workspace", + "go.work.sum": "Go Checksums", + "godot resource": "Godot Resource", + "golang": "Go", + "golo": "Golo", + "gosu": "Gosu", + "gotmpl": "Go Template", + "grace": "Grace", + "gradle": "Gradle", + "gradle kotlin dsl": "Gradle Kotlin DSL", + "grammatical framework": "Grammatical Framework", + "graph modeling language": "Graph Modeling Language", + "graphql": "GraphQL", + "graphviz (dot)": "Graphviz (DOT)", + "groff": "Roff", + "groovy": "Groovy", + "groovy server pages": "Groovy Server Pages", + "gsc": "GSC", + "gsp": "Groovy Server Pages", + "gtk": "GtkRC", + "gtk 1": "GtkRC", + "gtk 2": "GtkRC", + "gtkrc": "GtkRC", + "gts": "Glimmer TS", + "hack": "Hack", + "haml": "Haml", + "handlebars": "Handlebars", + "haproxy": "HAProxy", + "harbour": "Harbour", + "hare": "Hare", + "hash": "Checksums", + "hashes": "Checksums", + "hashicorp configuration language": "HCL", + "haskell": "Haskell", + "haxe": "Haxe", + "hbs": "Handlebars", + "hcl": "HCL", + "heex": "HTML+EEX", + "help": "Vim Help File", + "hip": "HIP", + "hiveql": "HiveQL", + "hls playlist": "M3U", + "hlsl": "HLSL", + "hocon": "HOCON", + "holyc": "HolyC", + "hoon": "hoon", + "hosts": "Hosts File", + "hosts file": "Hosts File", + "html": "HTML", + "html+django": "Jinja", + "html+ecr": "HTML+ECR", + "html+eex": "HTML+EEX", + "html+erb": "HTML+ERB", + "html+jinja": "Jinja", + "html+php": "HTML+PHP", + "html+razor": "HTML+Razor", + "html+ruby": "HTML+ERB", + "htmlbars": "Handlebars", + "htmldjango": "Jinja", + "http": "HTTP", + "hurl": "Hurl", + "hxml": "HXML", + "hy": "Hy", + "hylang": "Hy", + "hyphy": "HyPhy", + "i7": "Inform 7", + "ical": "iCalendar", + "icalendar": "iCalendar", + "idl": "IDL", + "idris": "Idris", + "ignore": "Ignore List", + "ignore list": "Ignore List", + "igor": "IGOR Pro", + "igor pro": "IGOR Pro", + "igorpro": "IGOR Pro", + "ijm": "ImageJ Macro", + "il assembly": "IL Assembly", + "ilasm": "IL Assembly", + "ile rpg": "RPGLE", + "imagej macro": "ImageJ Macro", + "imba": "Imba", + "imhex": "ImHex Pattern Language", + "imhex pattern language": "ImHex Pattern Language", + "imhexpatternlanguage": "ImHex Pattern Language", + "imhexpl": "ImHex Pattern Language", + "inc": "PHP", + "inform 7": "Inform 7", + "inform7": "Inform 7", + "ini": "INI", + "ink": "Ink", + "inno setup": "Inno Setup", + "inputrc": "Readline Config", + "io": "Io", + "ioke": "Ioke", + "ipython notebook": "Jupyter Notebook", + "irc": "IRC log", + "irc log": "IRC log", + "irc logs": "IRC log", + "isabelle": "Isabelle", + "isabelle root": "Isabelle ROOT", + "ispc": "ISPC", + "j": "J", + "jac": "Jac", + "jai": "Jai", + "janet": "Janet", + "jar manifest": "JAR Manifest", + "jasmin": "Jasmin", + "jass": "JASS", + "jass2": "JASS", + "java": "Java", + "java properties": "Java Properties", + "java server page": "Groovy Server Pages", + "java server pages": "Java Server Pages", + "java template engine": "Java Template Engine", + "javascript": "JavaScript", + "javascript+erb": "JavaScript+ERB", + "jcl": "JCL", + "jest snapshot": "Jest Snapshot", + "jetbrains mps": "JetBrains MPS", + "jflex": "JFlex", + "jinja": "Jinja", + "jison": "Jison", + "jison lex": "Jison Lex", + "jolie": "Jolie", + "jq": "jq", + "jruby": "Ruby", + "js": "JavaScript", + "json": "JSON", + "json with comments": "JSON with Comments", + "json5": "JSON5", + "jsonc": "JSON with Comments", + "jsoniq": "JSONiq", + "jsonl": "JSON", + "jsonld": "JSONLD", + "jsonnet": "Jsonnet", + "jsp": "Java Server Pages", + "jte": "Java Template Engine", + "julia": "Julia", + "julia repl": "Julia REPL", + "jupyter notebook": "Jupyter Notebook", + "just": "Just", + "justfile": "Just", + "kaitai struct": "Kaitai Struct", + "kak": "KakouneScript", + "kakounescript": "KakouneScript", + "kakscript": "KakouneScript", + "kcl": "KCL", + "kdl": "KDL", + "kerboscript": "KerboScript", + "keyvalues": "Valve Data Format", + "kframework": "KFramework", + "kicad layout": "KiCad Layout", + "kicad legacy layout": "KiCad Legacy Layout", + "kicad schematic": "KiCad Schematic", + "kickstart": "Kickstart", + "kit": "Kit", + "koka": "Koka", + "kolmafia ash": "KoLmafia ASH", + "kotlin": "Kotlin", + "krl": "KRL", + "ksy": "Kaitai Struct", + "kusto": "Kusto", + "kvlang": "kvlang", + "labview": "LabVIEW", + "lambdapi": "Lambdapi", + "langium": "Langium", + "lark": "Lark", + "lasso": "Lasso", + "lassoscript": "Lasso", + "latex": "TeX", + "latte": "Latte", + "lean": "Lean", + "lean 4": "Lean 4", + "lean4": "Lean 4", + "leex": "HTML+EEX", + "leo": "Leo", + "less": "Less", + "less-css": "Less", + "lex": "Lex", + "lfe": "LFE", + "lhaskell": "Literate Haskell", + "lhs": "Literate Haskell", + "ligolang": "LigoLANG", + "lilypond": "LilyPond", + "limbo": "Limbo", + "linear programming": "Linear Programming", + "linker script": "Linker Script", + "linux kernel module": "Linux Kernel Module", + "liquid": "Liquid", + "liquidsoap": "Liquidsoap", + "lisp": "Common Lisp", + "litcoffee": "Literate CoffeeScript", + "literate agda": "Literate Agda", + "literate coffeescript": "Literate CoffeeScript", + "literate haskell": "Literate Haskell", + "live-script": "LiveScript", + "livecode script": "LiveCode Script", + "livescript": "LiveScript", + "llvm": "LLVM", + "llvm tablegen": "LLVM TableGen", + "lobster": "Lobster", + "logos": "Logos", + "logtalk": "Logtalk", + "lolcode": "LOLCODE", + "lookml": "LookML", + "loomscript": "LoomScript", + "ls": "LiveScript", + "lsl": "LSL", + "ltspice symbol": "LTspice Symbol", + "lua": "Lua", + "luau": "Luau", + "m": "M", + "m2": "Macaulay2", + "m3u": "M3U", + "m3u playlist": "M3U", + "m4": "M4", + "m4sugar": "M4Sugar", + "m68k": "Motorola 68K Assembly", + "macaulay2": "Macaulay2", + "macruby": "Ruby", + "mail": "E-mail", + "make": "Makefile", + "makefile": "Makefile", + "mako": "Mako", + "man": "Roff", + "man page": "Roff", + "man-page": "Roff", + "manpage": "Roff", + "markdown": "Markdown", + "marko": "Marko", + "markojs": "Marko", + "mask": "Mask", + "mathematica": "Wolfram Language", + "mathematical programming system": "Mathematical Programming System", + "matlab": "MATLAB", + "maven pom": "Maven POM", + "max": "Max", + "max/msp": "Max", + "maxmsp": "Max", + "maxscript": "MAXScript", + "mbox": "E-mail", + "mcfunction": "mcfunction", + "md": "Markdown", + "mdoc": "Roff", + "mdsvex": "mdsvex", + "mdx": "MDX", + "mediawiki": "Wikitext", + "mercury": "Mercury", + "mermaid": "Mermaid", + "mermaid example": "Mermaid", + "meson": "Meson", + "metal": "Metal", + "metta": "MeTTa", + "mf": "Makefile", + "microsoft developer studio project": "Microsoft Developer Studio Project", + "microsoft visual studio solution": "Microsoft Visual Studio Solution", + "minid": "MiniD", + "miniscript": "MiniScript", + "miniyaml": "MiniYAML", + "minizinc": "MiniZinc", + "minizinc data": "MiniZinc Data", + "mint": "Mint", + "mirah": "Mirah", + "mirc script": "mIRC Script", + "mlir": "MLIR", + "mma": "Wolfram Language", + "modelica": "Modelica", + "modula-2": "Modula-2", + "modula-3": "Modula-3", + "module management system": "Module Management System", + "mojo": "Mojo", + "monkey": "Monkey", + "monkey c": "Monkey C", + "moocode": "Moocode", + "moonbit": "MoonBit", + "moonscript": "MoonScript", + "motoko": "Motoko", + "motorola 68k assembly": "Motorola 68K Assembly", + "move": "Move", + "mps": "JetBrains MPS", + "mql4": "MQL4", + "mql5": "MQL5", + "msil": "IL Assembly", + "mtml": "MTML", + "muf": "MUF", + "mumps": "M", + "mupad": "mupad", + "muse": "Muse", + "mustache": "Mustache", + "myghty": "Myghty", + "nanorc": "nanorc", + "nargo": "Noir", + "nasal": "Nasal", + "nasl": "NASL", + "nasm": "Assembly", + "ncl": "NCL", + "ne-on": "NEON", + "nearley": "Nearley", + "nemerle": "Nemerle", + "neon": "NEON", + "neosnippet": "Vim Snippet", + "nesc": "nesC", + "netlinx": "NetLinx", + "netlinx+erb": "NetLinx+ERB", + "netlogo": "NetLogo", + "nette object notation": "NEON", + "newlisp": "NewLisp", + "nextflow": "Nextflow", + "nginx": "Nginx", + "nginx configuration file": "Nginx", + "nickel": "Nickel", + "nim": "Nim", + "ninja": "Ninja", + "nit": "Nit", + "nix": "Nix", + "nixos": "Nix", + "njk": "Nunjucks", + "nl": "NL", + "nmodl": "NMODL", + "node": "JavaScript", + "noir": "Noir", + "npm config": "NPM Config", + "npmrc": "NPM Config", + "nroff": "Roff", + "nsis": "NSIS", + "nu": "Nu", + "nu-script": "Nushell", + "numpy": "NumPy", + "nunjucks": "Nunjucks", + "nush": "Nu", + "nushell": "Nushell", + "nushell-script": "Nushell", + "nvim": "Vim script", + "nwscript": "NWScript", + "oasv2": "OpenAPI Specification v2", + "oasv2-json": "OASv2-json", + "oasv2-yaml": "OASv2-yaml", + "oasv3": "OpenAPI Specification v3", + "oasv3-json": "OASv3-json", + "oasv3-yaml": "OASv3-yaml", + "oberon": "Oberon", + "obj-c": "Objective-C", + "obj-c++": "Objective-C++", + "obj-j": "Objective-J", + "objc": "Objective-C", + "objc++": "Objective-C++", + "objdump": "ObjDump", + "object data instance notation": "Object Data Instance Notation", + "objective-c": "Objective-C", + "objective-c++": "Objective-C++", + "objective-j": "Objective-J", + "objectivec": "Objective-C", + "objectivec++": "Objective-C++", + "objectivej": "Objective-J", + "objectpascal": "Pascal", + "objectscript": "ObjectScript", + "objj": "Objective-J", + "ocaml": "OCaml", + "octave": "MATLAB", + "odin": "Odin", + "odin-lang": "Odin", + "odinlang": "Odin", + "omgrofl": "Omgrofl", + "omnet++ msg": "OMNeT++ MSG", + "omnet++ ned": "OMNeT++ NED", + "omnetpp-msg": "OMNeT++ MSG", + "omnetpp-ned": "OMNeT++ NED", + "oncrpc": "RPC", + "ooc": "ooc", + "opa": "Opa", + "opal": "Opal", + "open policy agent": "Open Policy Agent", + "openapi specification v2": "OpenAPI Specification v2", + "openapi specification v3": "OpenAPI Specification v3", + "opencl": "OpenCL", + "openedge": "OpenEdge ABL", + "openedge abl": "OpenEdge ABL", + "openqasm": "OpenQASM", + "openrc": "OpenRC runscript", + "openrc runscript": "OpenRC runscript", + "openscad": "OpenSCAD", + "openstep property list": "OpenStep Property List", + "opentofu": "HCL", + "opentype feature file": "OpenType Feature File", + "option list": "Option List", + "opts": "Option List", + "opy": "OverPy", + "org": "Org", + "osascript": "AppleScript", + "overpassql": "OverpassQL", + "overpy": "OverPy", + "ox": "Ox", + "oxygene": "Oxygene", + "oz": "Oz", + "p4": "P4", + "pact": "Pact", + "pan": "Pan", + "pandoc": "Markdown", + "papyrus": "Papyrus", + "parrot": "Parrot", + "parrot assembly": "Parrot Assembly", + "parrot internal representation": "Parrot Internal Representation", + "pascal": "Pascal", + "pasm": "Parrot Assembly", + "pawn": "Pawn", + "pcbnew": "KiCad Layout", + "pddl": "PDDL", + "peg.js": "PEG.js", + "pep8": "Pep8", + "perl": "Perl", + "perl-6": "Raku", + "perl6": "Raku", + "php": "PHP", + "pic": "Pic", + "pickle": "Pickle", + "picolisp": "PicoLisp", + "piglatin": "PigLatin", + "pikchr": "Pic", + "pike": "Pike", + "pip requirements": "Pip Requirements", + "pir": "Parrot Internal Representation", + "pkg-config": "pkg-config", + "pkgconf": "pkg-config", + "pkl": "Pkl", + "plain text": "Text", + "plantuml": "PlantUML", + "plpgsql": "PLpgSQL", + "plsql": "PLSQL", + "pod": "Pod", + "pod 6": "Pod 6", + "pogoscript": "PogoScript", + "polar": "Polar", + "pony": "Pony", + "portugol": "Portugol", + "posh": "PowerShell", + "postcss": "PostCSS", + "postscr": "PostScript", + "postscript": "PostScript", + "pot": "Gettext Catalog", + "pov-ray": "POV-Ray SDL", + "pov-ray sdl": "POV-Ray SDL", + "povray": "POV-Ray SDL", + "power query": "Power Query", + "powerbuilder": "PowerBuilder", + "powerquery": "Power Query", + "powershell": "PowerShell", + "praat": "Praat", + "prisma": "Prisma", + "pro*c": "Pro*C", + "processing": "Processing", + "procfile": "Procfile", + "progress": "OpenEdge ABL", + "proguard": "Proguard", + "prolog": "Prolog", + "promela": "Promela", + "propeller spin": "Propeller Spin", + "proto": "Protocol Buffer", + "protobuf": "Protocol Buffer", + "protobuf text format": "Protocol Buffer Text Format", + "protocol buffer": "Protocol Buffer", + "protocol buffer text format": "Protocol Buffer Text Format", + "protocol buffers": "Protocol Buffer", + "public key": "Public Key", + "pug": "Pug", + "puppet": "Puppet", + "pure data": "Pure Data", + "purebasic": "PureBasic", + "purescript": "PureScript", + "pwsh": "PowerShell", + "py": "Python", + "py3": "Python", + "pycon": "Python console", + "pyret": "Pyret", + "pyrex": "Cython", + "python": "Python", + "python console": "Python console", + "python traceback": "Python traceback", + "python3": "Python", + "q": "q", + "q#": "Q#", + "qb": "QuickBASIC", + "qb64": "QuickBASIC", + "qbasic": "QuickBASIC", + "ql": "CodeQL", + "qmake": "QMake", + "qml": "QML", + "qsharp": "Q#", + "qt script": "Qt Script", + "quake": "Quake", + "quakec": "QuakeC", + "quartus simulation ip": "Quartus Simulation IP", + "quickbasic": "QuickBASIC", + "quint": "Quint", + "r": "R", + "racket": "Racket", + "ragel": "Ragel", + "ragel-rb": "Ragel", + "ragel-ruby": "Ragel", + "rake": "Ruby", + "raku": "Raku", + "raml": "RAML", + "rascal": "Rascal", + "rascript": "RAScript", + "raw": "Raw token data", + "raw token data": "Raw token data", + "razor": "HTML+Razor", + "rb": "Ruby", + "rbs": "RBS", + "rbx": "Ruby", + "rdoc": "RDoc", + "readline": "Readline Config", + "readline config": "Readline Config", + "realbasic": "REALbasic", + "reason": "Reason", + "reasonligo": "ReasonLIGO", + "rebol": "Rebol", + "record jar": "Record Jar", + "red": "Red", + "red/system": "Red", + "redcode": "Redcode", + "redirect rules": "Redirect Rules", + "redirects": "Redirect Rules", + "redscript": "Redscript", + "regex": "Regular Expression", + "regexp": "Regular Expression", + "regular expression": "Regular Expression", + "ren'py": "Ren'Py", + "renderscript": "RenderScript", + "renpy": "Ren'Py", + "rescript": "ReScript", + "restructuredtext": "reStructuredText", + "rexx": "REXX", + "rez": "Rez", + "rhai": "Rhai", + "rhtml": "HTML+ERB", + "rich text format": "Rich Text Format", + "ring": "Ring", + "riot": "Riot", + "rmarkdown": "RMarkdown", + "robotframework": "RobotFramework", + "robots": "Robots Exclusion Rules", + "robots exclusion rules": "Robots Exclusion Rules", + "robots txt": "Robots Exclusion Rules", + "robotx.txt": "Robots Exclusion Rules", + "roc": "Roc", + "rocq": "Rocq Prover", + "rocq prover": "Rocq Prover", + "roff": "Roff", + "roff manpage": "Roff Manpage", + "ron": "RON", + "ros interface": "ROS Interface", + "rosmsg": "ROS Interface", + "rouge": "Rouge", + "routeros script": "RouterOS Script", + "rpc": "RPC", + "rpcgen": "RPC", + "rpgle": "RPGLE", + "rpm spec": "RPM Spec", + "rs": "Rust", + "rs-274x": "Gerber Image", + "rscript": "R", + "rss": "XML", + "rst": "reStructuredText", + "ruby": "Ruby", + "runoff": "RUNOFF", + "rust": "Rust", + "rusthon": "Python", + "sage": "Sage", + "sail": "Sail", + "salt": "Salt", + "saltstack": "Salt", + "saltstate": "Salt", + "sarif": "JSON", + "sas": "SAS", + "sass": "Sass", + "scala": "Scala", + "scaml": "Scaml", + "scenic": "Scenic", + "scheme": "Scheme", + "scilab": "Scilab", + "scss": "SCSS", + "sdc": "Tcl", + "sed": "sed", + "self": "Self", + "selinux kernel policy language": "SELinux Policy", + "selinux policy": "SELinux Policy", + "sepolicy": "SELinux Policy", + "sfv": "Simple File Verification", + "sh": "Shell", + "shaderlab": "ShaderLab", + "shell": "Shell", + "shell-script": "Shell", + "shellcheck config": "ShellCheck Config", + "shellcheckrc": "ShellCheck Config", + "shellsession": "ShellSession", + "shen": "Shen", + "sieve": "Sieve", + "simple file verification": "Simple File Verification", + "singularity": "Singularity", + "sip": "SIP", + "slang": "Slang", + "slash": "Slash", + "slice": "Slice", + "slim": "Slim", + "slint": "Slint", + "smali": "Smali", + "smalltalk": "Smalltalk", + "smarty": "Smarty", + "smithy": "Smithy", + "sml": "Standard ML", + "smpl": "SmPL", + "smt": "SMT", + "snakefile": "Snakemake", + "snakemake": "Snakemake", + "snipmate": "Vim Snippet", + "snippet": "YASnippet", + "solidity": "Solidity", + "soong": "Soong", + "sourcemod": "SourcePawn", + "sourcepawn": "SourcePawn", + "soy": "Closure Templates", + "sparql": "SPARQL", + "specfile": "RPM Spec", + "spicedb schema": "SpiceDB Schema", + "spline font database": "Spline Font Database", + "splus": "R", + "sqf": "SQF", + "sql": "SQL", + "sqlpl": "SQLPL", + "sqlrpgle": "RPGLE", + "squeak": "Smalltalk", + "squirrel": "Squirrel", + "srecode template": "SRecode Template", + "ssh config": "SSH Config", + "ssh_config": "SSH Config", + "sshconfig": "SSH Config", + "sshd_config": "SSH Config", + "sshdconfig": "SSH Config", + "stan": "Stan", + "standard ml": "Standard ML", + "star": "STAR", + "starlark": "Starlark", + "stata": "Stata", + "stl": "STL", + "stla": "STL", + "ston": "STON", + "stringtemplate": "StringTemplate", + "stylus": "Stylus", + "subrip text": "SubRip Text", + "sugarss": "SugarSS", + "sum": "Checksums", + "sums": "Checksums", + "supercollider": "SuperCollider", + "surql": "SurrealQL", + "surrealql": "SurrealQL", + "survex data": "Survex data", + "svelte": "Svelte", + "svg": "SVG", + "sway": "Sway", + "sweave": "Sweave", + "swift": "Swift", + "swig": "SWIG", + "systemverilog": "SystemVerilog", + "tab-seperated values": "TSV", + "tablegen": "LLVM TableGen", + "tabular model definition language": "TMDL", + "tact": "Tact", + "talon": "Talon", + "tape": "Tape", + "tcl": "Tcl", + "tcsh": "Tcsh", + "tea": "Tea", + "teal": "Teal", + "templ": "templ", + "terra": "Terra", + "terraform": "HCL", + "terraform template": "Terraform Template", + "tex": "TeX", + "texinfo": "Texinfo", + "text": "Text", + "text proto": "Protocol Buffer Text Format", + "textgrid": "TextGrid", + "textile": "Textile", + "textmate properties": "TextMate Properties", + "thrift": "Thrift", + "ti program": "TI Program", + "tl": "Type Language", + "tl-verilog": "TL-Verilog", + "tla": "TLA", + "tm-properties": "TextMate Properties", + "tmdl": "TMDL", + "toit": "Toit", + "tolk": "Tolk", + "toml": "TOML", + "topojson": "JSON", + "tor config": "Tor Config", + "torrc": "Tor Config", + "traveling salesman problem": "TSPLIB data", + "travelling salesman problem": "TSPLIB data", + "tree-sitter query": "Tree-sitter Query", + "troff": "Roff", + "ts": "TypeScript", + "tsp": "TypeSpec", + "tsplib data": "TSPLIB data", + "tsq": "Tree-sitter Query", + "tsql": "TSQL", + "tsv": "TSV", + "tsx": "TSX", + "turing": "Turing", + "turtle": "Turtle", + "twig": "Twig", + "txl": "TXL", + "typ": "Typst", + "type language": "Type Language", + "typescript": "TypeScript", + "typescriptreact": "TSX", + "typespec": "TypeSpec", + "typst": "Typst", + "ucode": "ucode", + "udiff": "Diff", + "ultisnip": "Vim Snippet", + "ultisnips": "Vim Snippet", + "unified parallel c": "Unified Parallel C", + "unity3d asset": "Unity3D Asset", + "unix asm": "Unix Assembly", + "unix assembly": "Unix Assembly", + "uno": "Uno", + "unrealscript": "UnrealScript", + "untyped plutus core": "Untyped Plutus Core", + "ur": "UrWeb", + "ur/web": "UrWeb", + "urweb": "UrWeb", + "v": "V", + "vala": "Vala", + "valve data format": "Valve Data Format", + "vb .net": "Visual Basic .NET", + "vb 6": "Visual Basic 6.0", + "vb.net": "Visual Basic .NET", + "vb6": "Visual Basic 6.0", + "vba": "VBA", + "vbnet": "Visual Basic .NET", + "vbscript": "VBScript", + "vcard": "vCard", + "vcl": "VCL", + "vdf": "Valve Data Format", + "velocity": "Velocity Template Language", + "velocity template language": "Velocity Template Language", + "vento": "Vento", + "verilog": "Verilog", + "verse": "Verse", + "vespa": "Vespa Schema Definition", + "vespa schema definition": "Vespa Schema Definition", + "vhdl": "VHDL", + "vhs": "Tape", + "vim": "Vim script", + "vim help file": "Vim Help File", + "vim script": "Vim script", + "vim snippet": "Vim Snippet", + "vimhelp": "Vim Help File", + "viml": "Vim script", + "vimscript": "Vim script", + "virtual contact file": "vCard", + "visual basic": "Visual Basic .NET", + "visual basic .net": "Visual Basic .NET", + "visual basic 6": "Visual Basic 6.0", + "visual basic 6.0": "Visual Basic 6.0", + "visual basic classic": "Visual Basic 6.0", + "visual basic for applications": "VBA", + "vlang": "V", + "volt": "Volt", + "vtl": "Velocity Template Language", + "vtt": "WebVTT", + "vue": "Vue", + "vyper": "Vyper", + "wasm": "WebAssembly", + "wast": "WebAssembly", + "wavefront material": "Wavefront Material", + "wavefront object": "Wavefront Object", + "wdl": "WDL", + "web ontology language": "Web Ontology Language", + "webassembly": "WebAssembly", + "webassembly interface type": "WebAssembly Interface Type", + "webidl": "WebIDL", + "webvtt": "WebVTT", + "wget config": "Wget Config", + "wgetrc": "Wget Config", + "wgsl": "WGSL", + "whiley": "Whiley", + "wiki": "Wikitext", + "wikitext": "Wikitext", + "win32 message file": "Win32 Message File", + "winbatch": "Batchfile", + "windows registry entries": "Windows Registry Entries", + "wisp": "wisp", + "wit": "WebAssembly Interface Type", + "witcher script": "Witcher Script", + "wl": "Wolfram Language", + "wolfram": "Wolfram Language", + "wolfram lang": "Wolfram Language", + "wolfram language": "Wolfram Language", + "wollok": "Wollok", + "workflow description language": "WDL", + "world of warcraft addon data": "World of Warcraft Addon Data", + "wren": "Wren", + "wrenlang": "Wren", + "wsdl": "XML", + "x bitmap": "X BitMap", + "x font directory index": "X Font Directory Index", + "x pixmap": "X PixMap", + "x10": "X10", + "xbase": "xBase", + "xbm": "X BitMap", + "xc": "XC", + "xcompose": "XCompose", + "xdc": "Tcl", + "xdr": "RPC", + "xhtml": "HTML", + "xmake": "Xmake", + "xml": "XML", + "xml property list": "XML Property List", + "xml+genshi": "Genshi", + "xml+kid": "Genshi", + "xojo": "Xojo", + "xonsh": "Xonsh", + "xpages": "XPages", + "xpm": "X PixMap", + "xproc": "XProc", + "xquery": "XQuery", + "xs": "XS", + "xsd": "XML", + "xsl": "XSLT", + "xslt": "XSLT", + "xten": "X10", + "xtend": "Xtend", + "yacc": "Yacc", + "yaml": "YAML", + "yang": "YANG", + "yara": "YARA", + "yas": "YASnippet", + "yasnippet": "YASnippet", + "yml": "YAML", + "yul": "Yul", + "zap": "ZAP", + "zeek": "Zeek", + "zenscript": "ZenScript", + "zephir": "Zephir", + "zig": "Zig", + "zil": "ZIL", + "zimpl": "Zimpl", + "zmodel": "Zmodel", + "zsh": "Shell" +} diff --git a/src/features/organizations/data/popular.ts b/src/features/organizations/data/popular.ts new file mode 100644 index 0000000..03872ea --- /dev/null +++ b/src/features/organizations/data/popular.ts @@ -0,0 +1,51 @@ +import type { OrganizationSuggestion, TechnologySuggestion } from "../types"; + +export const POPULAR_ORGANIZATIONS: OrganizationSuggestion[] = [ + { login: "vercel", avatarUrl: "https://avatars.githubusercontent.com/u/14985020", description: "Develop. Preview. Ship." }, + { login: "facebook", avatarUrl: "https://avatars.githubusercontent.com/u/69631", description: "Meta Open Source" }, + { login: "microsoft", avatarUrl: "https://avatars.githubusercontent.com/u/6154722", description: "Open source projects and samples from Microsoft" }, + { login: "google", avatarUrl: "https://avatars.githubusercontent.com/u/1342004", description: "Google Open Source" }, + { login: "apache", avatarUrl: "https://avatars.githubusercontent.com/u/47359", description: "The Apache Software Foundation" }, + { login: "spring-projects", avatarUrl: "https://avatars.githubusercontent.com/u/422204", description: "Spring Framework & Projects" }, + { login: "kubernetes", avatarUrl: "https://avatars.githubusercontent.com/u/13629408", description: "Production-Grade Container Orchestration" }, + { login: "tailwindlabs", avatarUrl: "https://avatars.githubusercontent.com/u/67104417", description: "Tailwind CSS and UI tools" }, + { login: "nodejs", avatarUrl: "https://avatars.githubusercontent.com/u/9950313", description: "Node.js JavaScript runtime" }, + { login: "angular", avatarUrl: "https://avatars.githubusercontent.com/u/139426", description: "Angular web framework" }, + { login: "mozilla", avatarUrl: "https://avatars.githubusercontent.com/u/131524", description: "Mozilla open source projects" }, + { login: "rust-lang", avatarUrl: "https://avatars.githubusercontent.com/u/5430905", description: "Empowering everyone to build reliable and efficient software" }, + { login: "golang", avatarUrl: "https://avatars.githubusercontent.com/u/4314092", description: "The Go Programming Language" }, + { login: "flutter", avatarUrl: "https://avatars.githubusercontent.com/u/14101776", description: "Flutter makes it easy and fast to build beautiful apps" }, + { login: "netflix", avatarUrl: "https://avatars.githubusercontent.com/u/913567", description: "Netflix Open Source" }, + { login: "prisma", avatarUrl: "https://avatars.githubusercontent.com/u/17219219", description: "Next-generation ORM for Node.js & TypeScript" }, + { login: "sveltejs", avatarUrl: "https://avatars.githubusercontent.com/u/23617963", description: "Cybernetically enhanced web apps" }, + { login: "vitejs", avatarUrl: "https://avatars.githubusercontent.com/u/65625612", description: "Next Generation Frontend Tooling" }, +]; + +export const POPULAR_TECHNOLOGIES: TechnologySuggestion[] = [ + { name: "TypeScript", type: "language" }, + { name: "JavaScript", type: "language" }, + { name: "Python", type: "language" }, + { name: "Java", type: "language" }, + { name: "Go", type: "language" }, + { name: "Rust", type: "language" }, + { name: "C++", type: "language" }, + { name: "C#", type: "language" }, + { name: "Ruby", type: "language" }, + { name: "PHP", type: "language" }, + { name: "Swift", type: "language" }, + { name: "Kotlin", type: "language" }, + { name: "React", type: "framework" }, + { name: "Next.js", type: "framework" }, + { name: "Vue", type: "framework" }, + { name: "Angular", type: "framework" }, + { name: "Spring Boot", type: "framework" }, + { name: "Node.js", type: "framework" }, + { name: "Kubernetes", type: "framework" }, + { name: "Docker", type: "framework" }, + { name: "Django", type: "framework" }, + { name: "Tailwind CSS", type: "framework" }, + { name: "Svelte", type: "framework" }, + { name: "GraphQL", type: "framework" }, + { name: "Flutter", type: "framework" }, + { name: "Express", type: "framework" }, +]; diff --git a/src/features/organizations/filters.ts b/src/features/organizations/filters.ts new file mode 100644 index 0000000..745bb36 --- /dev/null +++ b/src/features/organizations/filters.ts @@ -0,0 +1,113 @@ +import type { + OrganizationIssueFilters, + OrganizationIssueSort, + OrganizationIssueStatus, +} from "./types"; + +export const DEFAULT_ORGANIZATION_FILTERS: OrganizationIssueFilters = { + org: "", + tech: "", + repository: "", + status: "open", + sort: "updated", + page: 1, +}; + +const SUPPORTED_STATUSES = new Set([ + "open", + "closed", + "all", +]); + +const SUPPORTED_SORTS = new Set([ + "updated", + "created", + "comments", +]); + +export function readOrganizationFilters( + params: URLSearchParams, +): OrganizationIssueFilters { + const statusParam = params.get("status"); + const sortParam = params.get("sort"); + const pageParam = Number.parseInt(params.get("page") ?? "1", 10); + + return { + org: (params.get("org") ?? "").trim(), + tech: (params.get("tech") ?? "").trim(), + repository: (params.get("repository") ?? "").trim(), + status: SUPPORTED_STATUSES.has(statusParam as OrganizationIssueStatus) + ? (statusParam as OrganizationIssueStatus) + : "open", + sort: SUPPORTED_SORTS.has(sortParam as OrganizationIssueSort) + ? (sortParam as OrganizationIssueSort) + : "updated", + page: Number.isFinite(pageParam) && pageParam > 0 ? pageParam : 1, + }; +} + +function validateRepositoryFilter( + repository: string, + org: string, +): string | null { + const repo = repository.trim(); + if (!repo) return null; + if (repo.length > 140) { + return "Repository name must be 140 characters or fewer."; + } + const repoPattern = /^(?:[a-zA-Z0-9-]+\/)?[a-zA-Z0-9_.-]+$/; + if (!repoPattern.test(repo)) { + return "Repository name is invalid."; + } + if (repo.includes("/")) { + const [repoOwner] = repo.split("/"); + if (repoOwner.toLowerCase() !== org.trim().toLowerCase()) { + return `Repository must belong to the "${org}" organization.`; + } + } + return null; +} + +export function validateOrganizationFilters( + filters: OrganizationIssueFilters, +): string | null { + if (!filters.org.trim()) { + return "Organization is required."; + } + if (filters.org.length > 39 || !/^[a-zA-Z0-9-]+$/.test(filters.org.trim())) { + return "Organization name may contain up to 39 alphanumeric characters or hyphens."; + } + if (!filters.tech.trim()) { + return "Technology is required."; + } + if (filters.tech.length > 80) { + return "Technology must be 80 characters or fewer."; + } + const repoError = validateRepositoryFilter(filters.repository, filters.org); + if (repoError) { + return repoError; + } + if (!SUPPORTED_STATUSES.has(filters.status)) { + return "Unsupported status filter."; + } + if (!SUPPORTED_SORTS.has(filters.sort)) { + return "Unsupported sort filter."; + } + return null; +} + +export function organizationSearchParams( + filters: OrganizationIssueFilters, +): URLSearchParams { + const params = new URLSearchParams(); + if (filters.org.trim()) params.set("org", filters.org.trim()); + if (filters.tech.trim()) params.set("tech", filters.tech.trim()); + if (filters.repository.trim()) + params.set("repository", filters.repository.trim()); + if (filters.status && filters.status !== "open") + params.set("status", filters.status); + if (filters.sort && filters.sort !== "updated") + params.set("sort", filters.sort); + if (filters.page > 1) params.set("page", String(filters.page)); + return params; +} diff --git a/src/features/organizations/server/search.ts b/src/features/organizations/server/search.ts new file mode 100644 index 0000000..604a89f --- /dev/null +++ b/src/features/organizations/server/search.ts @@ -0,0 +1,294 @@ +import { + LANGUAGE_ALIASES, + TOPIC_ALIASES, +} from "@/features/issues/data/search-options"; +import { githubFetch } from "@/lib/github"; +import linguistLanguages from "../data/languages.json"; +import type { + OrganizationIssue, + OrganizationIssueFilters, + OrganizationIssueSearchResponse, +} from "../types"; + +const PAGE_SIZE = 24; + +const LANGUAGES = new Map([ + ...Object.entries(linguistLanguages), + ...Object.entries(LANGUAGE_ALIASES), +]); + +type SearchItem = { + node_id: string; + number: number; + title: string; + html_url: string; + repository_url: string; + user: { login: string; avatar_url?: string } | null; + state: "open" | "closed"; + labels: Array<{ name: string; color?: string }>; + comments: number; + created_at: string; + updated_at: string; +}; + +type SearchResult = { + total_count: number; + incomplete_results?: boolean; + items: SearchItem[]; +}; + +type PublicRepo = { + full_name: string; + private: boolean; + archived?: boolean; + topics?: string[]; +}; + +function normalize(value: string | null) { + return (value ?? "").trim().toLowerCase(); +} + +function resolveRepoFullName(org: string, repository: string): string { + const cleanRepo = repository.trim(); + if (cleanRepo.includes("/")) return cleanRepo; + return `${org.trim()}/${cleanRepo}`; +} + +const STATUS_QUERY: Record = { + open: "is:open", + closed: "is:closed", + all: "", +}; + +async function frameworkRepositories( + filters: OrganizationIssueFilters, + topic: string, +) { + if (filters.repository) { + const fullName = resolveRepoFullName(filters.org, filters.repository); + try { + const { data } = await githubFetch( + `https://api.github.com/repos/${fullName}`, + process.env.GITHUB_TOKEN, + 7200, + ); + const matches = + !data.private && !data.archived && data.topics?.includes(topic); + return { + names: matches ? [data.full_name] : [], + total: matches ? 1 : 0, + incomplete: false, + }; + } catch { + return { names: [], total: 0, incomplete: false }; + } + } + + const url = new URL("https://api.github.com/search/repositories"); + url.search = new URLSearchParams({ + q: `org:${filters.org} topic:${topic} archived:false is:public`, + sort: "updated", + order: "desc", + per_page: "20", + }).toString(); + + const { data } = await githubFetch<{ + items: PublicRepo[]; + total_count: number; + incomplete_results?: boolean; + }>(url.toString(), process.env.GITHUB_TOKEN, 7200); + + return { + names: data.items + .filter((repo) => !repo.private) + .map((repo) => repo.full_name), + total: data.total_count, + incomplete: !!data.incomplete_results, + }; +} + +function repositoryQueries(base: string, names: string[]) { + if (names.length === 0) return []; + const queries: string[] = []; + let query = base; + for (const name of names) { + const qualifier = ` repo:${name}`; + if ((query + qualifier).length > 256 && query !== base) { + queries.push(query); + query = base; + } + query += qualifier; + } + if (query !== base) queries.push(query); + return queries; +} + +export async function buildOrganizationIssueQueries( + filters: OrganizationIssueFilters, +) { + const normalized = normalize(filters.tech); + const language = Object.hasOwn(TOPIC_ALIASES, normalized) + ? undefined + : LANGUAGES.get(normalized); + + const statusPart = STATUS_QUERY[filters.status]; + const baseParts = ["is:issue", "is:public", "archived:false"]; + if (statusPart) baseParts.push(statusPart); + const base = baseParts.join(" "); + + const notices: string[] = []; + let queries: string[]; + + if (language) { + const scope = filters.repository + ? `repo:${resolveRepoFullName(filters.org, filters.repository)}` + : `org:${filters.org}`; + queries = [`${base} ${scope} language:"${language}"`]; + } else { + const topic = + TOPIC_ALIASES[normalized]?.topic ?? normalized.replaceAll(/\s+/g, "-"); + const repositories = await frameworkRepositories(filters, topic); + + if (repositories.names.length === 0) { + queries = []; + notices.push( + `No repositories in organization "${filters.org}" matched technology topic "${topic}".`, + ); + } else { + queries = repositoryQueries(base, repositories.names); + notices.push( + `Technology matches repository topic "${topic}".`, + ); + if (repositories.total > repositories.names.length) { + notices.push( + `Searching the ${repositories.names.length} most recently updated matching repositories out of ${repositories.total}. Choose a repository to narrow the search.`, + ); + } + if (repositories.incomplete) { + notices.push("GitHub returned incomplete repository discovery results."); + } + } + } + + return { queries, notices }; +} + +async function searchPage( + query: string, + sort: string, + page: number, + size: number, +) { + const url = new URL("https://api.github.com/search/issues"); + url.search = new URLSearchParams({ + q: query, + sort, + order: "desc", + page: String(page), + per_page: String(size), + }).toString(); + + return ( + await githubFetch(url.toString(), process.env.GITHUB_TOKEN) + ).data; +} + +function mapIssue(item: SearchItem): OrganizationIssue { + return { + id: item.node_id, + number: item.number, + title: item.title, + url: item.html_url, + repository: item.repository_url.split("/repos/")[1] ?? "", + author: item.user?.login ?? "ghost", + authorAvatarUrl: item.user?.avatar_url, + status: item.state, + labels: (item.labels ?? []).map((label) => ({ + name: label.name, + color: label.color, + })), + comments: item.comments, + createdAt: item.created_at, + updatedAt: item.updated_at, + }; +} + +export async function searchOrganizationIssues( + filters: OrganizationIssueFilters, +): Promise { + const { queries, notices } = await buildOrganizationIssueQueries(filters); + + if (queries.length === 0) { + return { + issues: [], + totalCount: 0, + page: filters.page, + hasMore: false, + query: "", + notices, + tokenConfigured: Boolean(process.env.GITHUB_TOKEN), + }; + } + + const prefixSize = filters.page * PAGE_SIZE; + const size = queries.length > 1 ? Math.min(100, prefixSize) : PAGE_SIZE; + const pages = queries.length > 1 ? Math.ceil(prefixSize / size) : 1; + + const results: Array<{ + total: number; + incomplete: boolean; + items: SearchItem[]; + }> = []; + + for (let start = 0; start < queries.length; start += 3) { + const batch = await Promise.all( + queries.slice(start, start + 3).map(async (query) => { + const responses: SearchResult[] = []; + for (let index = 0; index < pages; index++) { + responses.push( + await searchPage(query, filters.sort, index + 1, size), + ); + } + return { + total: responses[0]?.total_count ?? 0, + incomplete: responses.some((r) => r.incomplete_results), + items: responses.flatMap((r) => r.items), + }; + }), + ); + results.push(...batch); + } + + const totalCount = results.reduce((sum, r) => sum + r.total, 0); + const seen = new Set(); + const mergedItems = results + .flatMap((r) => r.items) + .filter((item) => { + if (seen.has(item.node_id)) return false; + seen.add(item.node_id); + return true; + }) + .sort((a, b) => { + if (filters.sort === "comments") return b.comments - a.comments; + if (filters.sort === "created") + return ( + new Date(b.created_at).getTime() - new Date(a.created_at).getTime() + ); + return ( + new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime() + ); + }); + + const startIndex = (filters.page - 1) * PAGE_SIZE; + const pageItems = mergedItems.slice(startIndex, startIndex + PAGE_SIZE); + + return { + issues: pageItems.map(mapIssue), + totalCount, + page: filters.page, + hasMore: filters.page < 10 && filters.page * PAGE_SIZE < totalCount, + query: queries.join(" OR "), + notices, + tokenConfigured: Boolean(process.env.GITHUB_TOKEN), + }; +} diff --git a/src/features/organizations/server/suggestions.ts b/src/features/organizations/server/suggestions.ts new file mode 100644 index 0000000..6e58ae7 --- /dev/null +++ b/src/features/organizations/server/suggestions.ts @@ -0,0 +1,153 @@ +import { githubFetch, isRateLimitError } from "@/lib/github"; +import { POPULAR_ORGANIZATIONS, POPULAR_TECHNOLOGIES } from "../data/popular"; +import linguistLanguages from "../data/languages.json"; +import type { + OrganizationSuggestion, + OrgRepositorySuggestion, + TechnologySuggestion, +} from "../types"; + +type GitHubUserItem = { + login: string; + avatar_url: string; + description?: string; +}; + +type GitHubRepoItem = { + name: string; + full_name: string; + stargazers_count: number; + description: string | null; + private: boolean; +}; + +// Map of all language names from Linguist +const LINGUIST_LANGUAGES: TechnologySuggestion[] = Array.from( + new Set(Object.values(linguistLanguages)), +).map((name) => ({ name, type: "language" })); + +export async function getOrganizationSuggestions( + query: string, +): Promise { + const trimmed = query.trim().toLowerCase(); + if (!trimmed) { + return POPULAR_ORGANIZATIONS.slice(0, 8); + } + + // Local popular matches + const localMatches = POPULAR_ORGANIZATIONS.filter((org) => + org.login.toLowerCase().includes(trimmed), + ); + + try { + const url = new URL("https://api.github.com/search/users"); + url.searchParams.set("q", `${trimmed} type:org`); + url.searchParams.set("per_page", "8"); + + const { data } = await githubFetch<{ items: GitHubUserItem[] }>( + url.toString(), + process.env.GITHUB_TOKEN, + 300, + ); + + const remoteSuggestions: OrganizationSuggestion[] = data.items.map( + (item) => ({ + login: item.login, + avatarUrl: item.avatar_url, + description: item.description ?? null, + }), + ); + + // Merge local and remote, deduplicating by login + const seen = new Set(); + const results: OrganizationSuggestion[] = []; + + for (const item of [...remoteSuggestions, ...localMatches]) { + const key = item.login.toLowerCase(); + if (!seen.has(key)) { + seen.add(key); + results.push(item); + } + if (results.length >= 8) break; + } + + return results; + } catch (error) { + if (isRateLimitError(error)) throw error; + // Fall back to local popular matches if network/remote fails + return localMatches.slice(0, 8); + } +} + +export function getTechnologySuggestions( + query: string, +): TechnologySuggestion[] { + const trimmed = query.trim().toLowerCase(); + if (!trimmed) { + return POPULAR_TECHNOLOGIES.slice(0, 10); + } + + const results: TechnologySuggestion[] = []; + const seen = new Set(); + + // Prioritize popular technologies + for (const tech of POPULAR_TECHNOLOGIES) { + if (tech.name.toLowerCase().includes(trimmed)) { + seen.add(tech.name.toLowerCase()); + results.push(tech); + } + if (results.length >= 10) return results; + } + + // Then add from linguist catalog + for (const lang of LINGUIST_LANGUAGES) { + const key = lang.name.toLowerCase(); + if (!seen.has(key) && key.includes(trimmed)) { + seen.add(key); + results.push(lang); + } + if (results.length >= 10) break; + } + + return results; +} + +export async function getRepositorySuggestions( + org: string, + query = "", +): Promise { + const cleanOrg = org.trim(); + if (!cleanOrg) return []; + + const cleanQuery = query.trim(); + const url = new URL("https://api.github.com/search/repositories"); + + const q = cleanQuery + ? `org:${cleanOrg} ${cleanQuery} in:name archived:false` + : `org:${cleanOrg} archived:false`; + + url.searchParams.set("q", q); + url.searchParams.set("sort", "stars"); + url.searchParams.set("order", "desc"); + url.searchParams.set("per_page", "8"); + + try { + const { data } = await githubFetch<{ items: GitHubRepoItem[] }>( + url.toString(), + process.env.GITHUB_TOKEN, + 180, + ); + + return data.items + .filter((repo) => !repo.private) + .map((repo) => ({ + name: repo.name, + fullName: repo.full_name, + stars: repo.stargazers_count, + description: repo.description, + })); + } catch (error) { + if (isRateLimitError(error)) throw error; + return []; + } +} diff --git a/src/features/organizations/types.ts b/src/features/organizations/types.ts new file mode 100644 index 0000000..467b65e --- /dev/null +++ b/src/features/organizations/types.ts @@ -0,0 +1,54 @@ +export type OrganizationSuggestion = { + login: string; + avatarUrl: string; + description?: string | null; +}; + +export type TechnologySuggestion = { + name: string; + type: "language" | "framework"; +}; + +export type OrgRepositorySuggestion = { + name: string; + fullName: string; + stars: number; + description: string | null; +}; + +export type OrganizationIssueStatus = "open" | "closed" | "all"; +export type OrganizationIssueSort = "updated" | "created" | "comments"; + +export type OrganizationIssueFilters = { + org: string; + tech: string; + repository: string; + status: OrganizationIssueStatus; + sort: OrganizationIssueSort; + page: number; +}; + +export type OrganizationIssue = { + id: string; + number: number; + title: string; + url: string; + repository: string; + author: string; + authorAvatarUrl?: string; + status: "open" | "closed"; + labels: Array<{ name: string; color?: string }>; + comments: number; + createdAt: string; + updatedAt: string; +}; + +export type OrganizationIssueSearchResponse = { + issues: OrganizationIssue[]; + totalCount: number; + page: number; + hasMore: boolean; + query: string; + notices: string[]; + tokenConfigured: boolean; +}; diff --git a/src/features/repositories/server/github-repository.ts b/src/features/repositories/server/github-repository.ts new file mode 100644 index 0000000..d0a810b --- /dev/null +++ b/src/features/repositories/server/github-repository.ts @@ -0,0 +1,141 @@ +import { + githubFetch, + RateLimitError, + computeRetryAfterSeconds, + isRateLimitResponse, +} from "@/lib/github"; +import { + scoreRepositoryResponsiveness, + unknownRepositoryResponsiveness, + type ResponsivenessIssue, + type ResponsivenessPullRequest, +} from "@/features/issues/lib/repository-responsiveness"; + +type GitHubCommunityProfileResponse = { + health_percentage: number; + files: Partial< + Record< + | "readme" + | "contributing" + | "license" + | "code_of_conduct" + | "issue_template" + | "pull_request_template", + { html_url?: string | null } | null + > + >; +}; + +type GitHubResponsivenessResponse = { + data?: { + repository?: { + issues: { nodes: ResponsivenessIssue[] }; + pullRequests: { nodes: ResponsivenessPullRequest[] }; + } | null; + }; + errors?: Array<{ message: string }>; +}; + +const RESPONSIVENESS_QUERY = ` + query RepositoryResponsiveness($owner: String!, $name: String!, $since: DateTime!) { + repository(owner: $owner, name: $name) { + issues(first: 20, orderBy: { field: CREATED_AT, direction: DESC }, filterBy: { since: $since }) { + nodes { + author { login } + closedAt + createdAt + labels(first: 10) { nodes { name } } + comments(first: 20) { + nodes { author { login } authorAssociation createdAt } + } + } + } + pullRequests(first: 20, orderBy: { field: CREATED_AT, direction: DESC }) { + nodes { authorAssociation createdAt mergedAt } + } + } + } +`; + +export async function getRepositoryResponsiveness( + fullName: string, + token = process.env.GITHUB_TOKEN, +) { + if (!token) { + return unknownRepositoryResponsiveness( + "Maintainer responsiveness data is unavailable", + ); + } + + const [owner, name] = fullName.split("/"); + if (!owner || !name) return unknownRepositoryResponsiveness(); + + const sinceDate = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000); + sinceDate.setUTCHours(Math.floor(sinceDate.getUTCHours() / 6) * 6, 0, 0, 0); + const since = sinceDate.toISOString(); + const response = await fetch("https://api.github.com/graphql", { + method: "POST", + headers: { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + "X-GitHub-Api-Version": "2022-11-28", + }, + body: JSON.stringify({ + query: RESPONSIVENESS_QUERY, + variables: { owner, name, since }, + }), + next: { revalidate: 21600 }, + }); + + if (!response.ok) { + const body = await response.text(); + const retryAfterSeconds = computeRetryAfterSeconds(response.headers); + + if ( + (response.status === 403 || response.status === 429) && + isRateLimitResponse(body) + ) { + throw new RateLimitError( + "GitHub API rate limit exceeded. Please wait a few minutes and try again.", + retryAfterSeconds, + ); + } + + throw new Error(`GitHub GraphQL error ${response.status}`); + } + const payload = (await response.json()) as GitHubResponsivenessResponse; + const repository = payload.data?.repository; + + if (!repository || payload.errors?.length) { + throw new Error( + payload.errors?.[0]?.message ?? "Repository analytics unavailable", + ); + } + + return scoreRepositoryResponsiveness( + repository.issues.nodes, + repository.pullRequests.nodes, + ); +} + +export async function getCommunityProfile(fullName: string, token?: string) { + const result = await githubFetch( + `https://api.github.com/repos/${fullName}/community/profile`, + token, + 21600, + ); + const files = result.data.files ?? {}; + + return { + healthPercentage: result.data.health_percentage, + documentation: { + readme: files.readme?.html_url ?? null, + contributing: files.contributing?.html_url ?? null, + license: files.license?.html_url ?? null, + codeOfConduct: files.code_of_conduct?.html_url ?? null, + issueTemplate: files.issue_template?.html_url ?? null, + pullRequestTemplate: files.pull_request_template?.html_url ?? null, + }, + }; +} diff --git a/src/lib/github.ts b/src/lib/github.ts new file mode 100644 index 0000000..fd8e0a5 --- /dev/null +++ b/src/lib/github.ts @@ -0,0 +1,125 @@ +export class RateLimitError extends Error { + retryAfterSeconds: number | null; + + constructor(message: string, retryAfterSeconds: number | null = null) { + super(message); + this.name = "RateLimitError"; + this.retryAfterSeconds = retryAfterSeconds; + } +} + +export function isRateLimitError(error: unknown): error is RateLimitError { + return error instanceof RateLimitError; +} + +function isAllowedGitHubPath(pathname: string) { + if ( + pathname === "/search/issues" || + pathname === "/search/repositories" || + pathname === "/search/users" + ) return true; + const [, resource, owner = "", name = "", ...remaining] = pathname.split("/"); + if ( + resource !== "repos" || + !/^[a-zA-Z0-9-]+$/.test(owner) || + !/^[a-zA-Z0-9_.-]+$/.test(name) || + name === "." || name === ".." + ) return false; + const suffix = remaining.length ? "/" + remaining.join("/") : ""; + if (suffix === "" || suffix === "/community/profile") return true; + return /^\/issues\/[1-9]\d*\/(?:comments|timeline)$/.test(suffix) || + /^\/pulls\/[1-9]\d*$/.test(suffix); +} + +export async function githubFetch( + url: string, + token?: string, + revalidate = 60, +) { + const target = new URL(url); + if ( + target.protocol !== "https:" || + target.hostname !== "api.github.com" || + target.port || + target.username || + target.password + ) { + throw new Error("Only the GitHub HTTPS API is allowed."); + } + if (!isAllowedGitHubPath(target.pathname)) { + throw new Error("Unsupported GitHub API endpoint."); + } + // Construct the destination from a fixed origin. Encode every input-bearing + // component instead of forwarding the caller's URL to the network boundary. + const path = target.pathname.slice(1).split("/").map((segment) => encodeURIComponent(segment)).join("/"); + const query = Array.from(target.searchParams, ([key, value]) => + encodeURIComponent(key) + "=" + encodeURIComponent(value), + ).join("&"); + const requestUrl = "https://api.github.com/" + path + (query ? "?" + query : ""); + const response = await fetch(requestUrl, { + redirect: "error", + headers: { + Accept: "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + next: { revalidate }, + }); + + if (!response.ok) { + const body = await response.text(); + const retryAfterSeconds = computeRetryAfterSeconds(response.headers); + + if ( + (response.status === 403 || response.status === 429) && + isRateLimitResponse(body) + ) { + throw new RateLimitError( + "GitHub API rate limit exceeded. Please wait a few minutes and try again.", + retryAfterSeconds, + ); + } + + throw new Error(`GitHub API error ${response.status}: ${body}`); + } + + return { + data: (await response.json()) as T, + rateLimitRemaining: response.headers.get("x-ratelimit-remaining"), + }; +} + +export function isRateLimitResponse(body: string): boolean { + const lower = body.toLowerCase(); + return ( + lower.includes("rate limit") || + lower.includes("rate_limit") || + lower.includes("api rate limit exceeded") || + lower.includes("secondary rate limit") + ); +} + +// GitHub's primary rate-limit responses commonly omit `retry-after` and +// instead provide `x-ratelimit-reset`, a Unix timestamp (seconds) for when +// the limit resets. Fall back to computing the delay from that header so we +// don't under-report the wait time with a default cooldown. +export function computeRetryAfterSeconds(headers: Headers): number | null { + const retryAfter = headers.get("retry-after"); + if (retryAfter) { + const parsed = Number.parseInt(retryAfter, 10); + if (!Number.isNaN(parsed)) { + return parsed; + } + } + + const resetHeader = headers.get("x-ratelimit-reset"); + if (resetHeader) { + const resetEpochSeconds = Number.parseInt(resetHeader, 10); + if (!Number.isNaN(resetEpochSeconds)) { + const nowEpochSeconds = Math.floor(Date.now() / 1000); + return Math.max(0, resetEpochSeconds - nowEpochSeconds); + } + } + + return null; +} diff --git a/tests/features/issues/components/issue-finder.test.tsx b/tests/features/issues/components/issue-finder.test.tsx index 7c8d5a9..e63e88e 100644 --- a/tests/features/issues/components/issue-finder.test.tsx +++ b/tests/features/issues/components/issue-finder.test.tsx @@ -755,7 +755,7 @@ describe("IssueFinder", () => { expect(await screen.findByText("Failed to load more issues.")).toBeTruthy(); }); - it("shows an unknown token status until a successful search reports it", async () => { + it("keeps server configuration out of the search overview", async () => { const fetchMock = vi.mocked(fetch); fetchMock .mockResolvedValueOnce( @@ -766,19 +766,21 @@ describe("IssueFinder", () => { ); render(); - expect(screen.getByText("unknown")).toBeTruthy(); + expect(screen.queryByText("GitHub token")).toBeNull(); const form = screen .getByRole("button", { name: "Search" }) .closest("form")!; fireEvent.submit(form); - expect(await screen.findByText("configured")).toBeTruthy(); + await screen.findByText("Opportunities"); + expect(screen.queryByText("GitHub token")).toBeNull(); + expect(screen.queryByText("configured")).toBeNull(); fireEvent.submit(form); expect( (await screen.findAllByText("Search failed")).length, ).toBeGreaterThan(0); - expect(screen.getByText("configured")).toBeTruthy(); + expect(screen.queryByText("GitHub token")).toBeNull(); }); it("writes successful searches to the URL without adding pagination entries", async () => { diff --git a/tests/features/issues/server/github-search.test.ts b/tests/features/issues/server/github-search.test.ts index d6145c9..08443d9 100644 --- a/tests/features/issues/server/github-search.test.ts +++ b/tests/features/issues/server/github-search.test.ts @@ -885,8 +885,8 @@ describe("repository digest GitHub queries", () => { stars: 250, }, ]); - expect(String(fetchMock.mock.calls[0][0])).toContain( - "widgets+in%3Aname%2Cdescription+archived%3Afalse", + expect(new URL(String(fetchMock.mock.calls[0][0])).searchParams.get("q")).toBe( + "widgets in:name,description archived:false", ); }); diff --git a/tests/features/organizations/api.test.ts b/tests/features/organizations/api.test.ts new file mode 100644 index 0000000..eceb877 --- /dev/null +++ b/tests/features/organizations/api.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, it, vi } from "vitest"; + +vi.mock("@/features/organizations/server/search", () => ({ + searchOrganizationIssues: vi.fn().mockResolvedValue({ + issues: [], + totalCount: 0, + page: 1, + hasMore: false, + query: "is:issue", + notices: [], + tokenConfigured: true, + }), +})); + +vi.mock("@/features/organizations/server/suggestions", () => ({ + getOrganizationSuggestions: vi.fn().mockResolvedValue([ + { login: "vercel", avatarUrl: "https://example.com/avatar.png" }, + ]), + getTechnologySuggestions: vi.fn().mockReturnValue([ + { name: "TypeScript", type: "language" }, + ]), + getRepositorySuggestions: vi.fn().mockResolvedValue([ + { name: "next.js", fullName: "vercel/next.js", stars: 1000, description: "Framework" }, + ]), +})); + +import { GET as getIssues } from "@/app/api/organizations/issues/route"; +import { GET as getOrgSuggestions } from "@/app/api/suggestions/organizations/route"; +import { GET as getTechSuggestions } from "@/app/api/suggestions/technologies/route"; +import { GET as getRepoSuggestions } from "@/app/api/suggestions/repositories/route"; + +describe("organization issues API", () => { + it("rejects request missing organization or technology", async () => { + const res = await getIssues( + new Request("http://localhost/api/organizations/issues?org=&tech="), + ); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toBe("Organization is required."); + }); + + it("accepts valid search request and returns payload", async () => { + const res = await getIssues( + new Request("http://localhost/api/organizations/issues?org=vercel&tech=TypeScript"), + ); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.query).toBe("is:issue"); + }); +}); + +describe("suggestions APIs", () => { + it("returns organization suggestions", async () => { + const res = await getOrgSuggestions( + new Request("http://localhost/api/suggestions/organizations?query=ver"), + ); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.organizations[0].login).toBe("vercel"); + }); + + it("returns technology suggestions", async () => { + const res = await getTechSuggestions( + new Request("http://localhost/api/suggestions/technologies?query=type"), + ); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.technologies[0].name).toBe("TypeScript"); + }); + + it("requires org parameter for repository suggestions", async () => { + const res = await getRepoSuggestions( + new Request("http://localhost/api/suggestions/repositories"), + ); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toBe("Organization parameter is required."); + }); + + it("returns repository suggestions when org is provided", async () => { + const res = await getRepoSuggestions( + new Request("http://localhost/api/suggestions/repositories?org=vercel&query=next"), + ); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.repositories[0].name).toBe("next.js"); + }); + + it("handles rate limits and errors in search API", async () => { + const { searchOrganizationIssues } = await import( + "@/features/organizations/server/search" + ); + const { RateLimitError } = await import("@/lib/github"); + + vi.mocked(searchOrganizationIssues).mockRejectedValueOnce( + new RateLimitError("Rate limit hit", 60), + ); + const rateLimitRes = await getIssues( + new Request("http://localhost/api/organizations/issues?org=vercel&tech=TypeScript"), + ); + expect(rateLimitRes.status).toBe(429); + + vi.mocked(searchOrganizationIssues).mockRejectedValueOnce( + new Error("Network exploded"), + ); + const errorRes = await getIssues( + new Request("http://localhost/api/organizations/issues?org=vercel&tech=TypeScript"), + ); + expect(errorRes.status).toBe(502); + }); + + it("handles rate limits and errors in suggestion APIs", async () => { + const { getOrganizationSuggestions, getRepositorySuggestions } = + await import("@/features/organizations/server/suggestions"); + const { RateLimitError } = await import("@/lib/github"); + + vi.mocked(getOrganizationSuggestions).mockRejectedValueOnce( + new RateLimitError("Rate limit", 30), + ); + const orgRateRes = await getOrgSuggestions( + new Request("http://localhost/api/suggestions/organizations?query=ver"), + ); + expect(orgRateRes.status).toBe(429); + + vi.mocked(getOrganizationSuggestions).mockRejectedValueOnce( + new Error("Server error"), + ); + const orgErrRes = await getOrgSuggestions( + new Request("http://localhost/api/suggestions/organizations?query=ver"), + ); + expect(orgErrRes.status).toBe(500); + + vi.mocked(getRepositorySuggestions).mockRejectedValueOnce( + new RateLimitError("Rate limit", 30), + ); + const repoRateRes = await getRepoSuggestions( + new Request("http://localhost/api/suggestions/repositories?org=vercel"), + ); + expect(repoRateRes.status).toBe(429); + + vi.mocked(getRepositorySuggestions).mockRejectedValueOnce( + new Error("Server error"), + ); + const repoErrRes = await getRepoSuggestions( + new Request("http://localhost/api/suggestions/repositories?org=vercel"), + ); + expect(repoErrRes.status).toBe(500); + }); +}); diff --git a/tests/features/organizations/autocomplete-input.test.tsx b/tests/features/organizations/autocomplete-input.test.tsx new file mode 100644 index 0000000..6ab8925 --- /dev/null +++ b/tests/features/organizations/autocomplete-input.test.tsx @@ -0,0 +1,128 @@ +// @vitest-environment jsdom +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { AutocompleteInput } from "@/features/organizations/components/autocomplete-input"; + +describe("AutocompleteInput", () => { + it("handles keyboard Escape and Tab keys", () => { + const onChange = vi.fn(); + render( + + value="test" + onChange={onChange} + fetchSuggestions={() => ["apple", "banana"]} + getSuggestionValue={(s) => s} + getSuggestionKey={(s) => s} + renderSuggestion={(s) => {s}} + />, + ); + + const input = screen.getByRole("combobox"); + fireEvent.focus(input); + fireEvent.keyDown(input, { key: "ArrowDown" }); + expect(input.getAttribute("aria-expanded")).toBe("true"); + + // Escape closes + fireEvent.keyDown(input, { key: "Escape" }); + expect(input.getAttribute("aria-expanded")).toBe("false"); + + // Tab closes + fireEvent.keyDown(input, { key: "ArrowDown" }); + fireEvent.keyDown(input, { key: "Tab" }); + expect(input.getAttribute("aria-expanded")).toBe("false"); + }); + + it("handles keyboard ArrowUp and mouse events", async () => { + const onChange = vi.fn(); + const onSelect = vi.fn(); + render( + + value="test" + onChange={onChange} + onSelect={onSelect} + fetchSuggestions={() => ["apple", "banana"]} + getSuggestionValue={(s) => s} + getSuggestionKey={(s) => s} + renderSuggestion={(s) => {s}} + debounceMs={0} + />, + ); + + const input = screen.getByRole("combobox"); + fireEvent.focus(input); + + // Wait for suggestions to load + const option = await screen.findByText("apple"); + fireEvent.mouseEnter(option); + + // ArrowUp + fireEvent.keyDown(input, { key: "ArrowUp" }); + // ArrowDown + fireEvent.keyDown(input, { key: "ArrowDown" }); + // Enter + fireEvent.keyDown(input, { key: "Enter" }); + expect(onChange).toHaveBeenCalled(); + }); + + it("handles disabled focus and unhighlighted Enter", () => { + const onChange = vi.fn(); + render( + + value="" + disabled + onChange={onChange} + fetchSuggestions={() => []} + getSuggestionValue={(s) => s} + getSuggestionKey={(s) => s} + renderSuggestion={(s) => {s}} + />, + ); + + const input = screen.getByRole("combobox"); + fireEvent.focus(input); + expect(input.getAttribute("aria-expanded")).toBe("false"); + }); + + it("ignores Enter when no suggestion is highlighted", () => { + const onChange = vi.fn(); + render( + + value="a" + onChange={onChange} + fetchSuggestions={() => ["apple"]} + getSuggestionValue={(s) => s} + getSuggestionKey={(s) => s} + renderSuggestion={(s) => {s}} + debounceMs={0} + />, + ); + + const input = screen.getByRole("combobox"); + fireEvent.focus(input); + // Enter with no highlighted index + fireEvent.keyDown(input, { key: "Enter" }); + expect(onChange).not.toHaveBeenCalled(); + }); + + it("renders with custom icon, ariaLabel, and inputClassName", () => { + render( + + value="" + onChange={vi.fn()} + fetchSuggestions={() => []} + getSuggestionValue={(s) => s} + getSuggestionKey={(s) => s} + renderSuggestion={(s) => {s}} + icon={Icon} + ariaLabel="Custom field" + inputClassName="custom-input-class" + />, + ); + + expect(screen.getByTestId("custom-icon")).toBeTruthy(); + const input = screen.getByRole("combobox"); + expect(input.getAttribute("aria-label")).toBe("Custom field"); + expect(input.className).toContain("custom-input-class"); + expect(input.className).toContain("pl-9"); + }); +}); diff --git a/tests/features/organizations/client.test.ts b/tests/features/organizations/client.test.ts new file mode 100644 index 0000000..01184ff --- /dev/null +++ b/tests/features/organizations/client.test.ts @@ -0,0 +1,66 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + fetchOrganizationData, + OrganizationClientError, +} from "@/features/organizations/client"; + +afterEach(() => vi.unstubAllGlobals()); + +describe("fetchOrganizationData", () => { + it("returns json data on successful response", async () => { + const fetchMock = vi + .fn() + .mockResolvedValue(new Response(JSON.stringify({ ok: true }))); + vi.stubGlobal("fetch", fetchMock); + + const data = await fetchOrganizationData<{ ok: boolean }>("/api/test"); + expect(data).toEqual({ ok: true }); + }); + + it("extracts error message and retry-after header on failure", async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ error: "Rate limited" }), { + status: 429, + headers: { "Retry-After": "45" }, + }), + ); + vi.stubGlobal("fetch", fetchMock); + + await expect(fetchOrganizationData("/api/test")).rejects.toThrow( + "Rate limited", + ); + + try { + await fetchOrganizationData("/api/test"); + } catch (err) { + expect(err).toBeInstanceOf(OrganizationClientError); + expect((err as OrganizationClientError).retryAfter).toBe(45); + } + }); + + it("handles non-JSON error responses gracefully", async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response("Internal server error", { + status: 500, + }), + ); + vi.stubGlobal("fetch", fetchMock); + + await expect(fetchOrganizationData("/api/test")).rejects.toThrow( + "Request failed (500)", + ); + }); + + it("handles JSON error responses without error field", async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ message: "Something broke" }), { + status: 500, + }), + ); + vi.stubGlobal("fetch", fetchMock); + + await expect(fetchOrganizationData("/api/test")).rejects.toThrow( + "Request failed (500)", + ); + }); +}); diff --git a/tests/features/organizations/dashboard.test.tsx b/tests/features/organizations/dashboard.test.tsx new file mode 100644 index 0000000..27be687 --- /dev/null +++ b/tests/features/organizations/dashboard.test.tsx @@ -0,0 +1,445 @@ +// @vitest-environment jsdom +import * as React from "react"; +import { + act, + cleanup, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const navigation = vi.hoisted(() => ({ query: "", push: vi.fn() })); +vi.mock("next/navigation", () => ({ + useSearchParams: () => new URLSearchParams(navigation.query), + useRouter: () => ({ push: navigation.push }), +})); +vi.mock("@/components/auth-controls", () => ({ AuthControls: () => null })); +vi.mock("@/components/theme-toggle", () => ({ ThemeToggle: () => null })); + +vi.mock("@/components/ui/select", () => ({ + Select: ({ value, onValueChange, children }: any) => { + let selectTrigger: any = null; + let selectItems: any[] = []; + React.Children.forEach(children, (child: any) => { + if (!child) return; + if (child.type?.name === "SelectTrigger" || child.props?.["aria-label"]) { + selectTrigger = child; + } + if (child.type?.name === "SelectContent" || child.props?.children) { + React.Children.forEach(child.props.children, (item: any) => { + if (item) selectItems.push(item); + }); + } + }); + + return ( + + ); + }, + SelectTrigger: ({ children }: any) => children, + SelectValue: ({ children }: any) => children, + SelectContent: ({ children }: any) => children, + SelectItem: ({ value, children }: any) => ( + + ), +})); + +import { OrganizationDashboard } from "@/features/organizations/components/organization-dashboard"; +import type { OrganizationIssue } from "@/features/organizations/types"; + +const sampleIssue: OrganizationIssue = { + id: "I_1", + number: 101, + title: "Hydration mismatch in client component", + url: "https://github.com/vercel/next.js/issues/101", + repository: "vercel/next.js", + author: "alice", + status: "open", + labels: [{ name: "bug", color: "d73a4a" }], + comments: 4, + createdAt: "2026-09-01T00:00:00Z", + updatedAt: "2026-09-02T00:00:00Z", +}; + +const searchResult = { + issues: [sampleIssue], + totalCount: 1, + page: 1, + hasMore: false, + query: "is:issue", + notices: [], + tokenConfigured: true, +}; + +const jsonResponse = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); + +beforeEach(() => { + navigation.query = ""; + navigation.push.mockReset(); +}); + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +describe("OrganizationDashboard", () => { + it("renders the empty state when no query is present", () => { + render(); + expect( + screen.getByText("Explore open-source issues by organization"), + ).toBeTruthy(); + }); + + it("disables the repository input until organization is filled", () => { + render(); + const repoInput = screen.getByPlaceholderText("Select an organization first"); + expect((repoInput as HTMLInputElement).disabled).toBe(true); + + const orgInput = screen.getByLabelText("Organization"); + fireEvent.change(orgInput, { target: { value: "vercel" } }); + + expect((repoInput as HTMLInputElement).disabled).toBe(false); + }); + + it("clears repository when organization changes", () => { + render(); + const orgInput = screen.getByLabelText("Organization"); + const repoInput = screen.getByLabelText("Repository (optional)"); + + fireEvent.change(orgInput, { target: { value: "vercel" } }); + fireEvent.change(repoInput, { target: { value: "next.js" } }); + expect((repoInput as HTMLInputElement).value).toBe("next.js"); + + // Change org + fireEvent.change(orgInput, { target: { value: "facebook" } }); + expect((repoInput as HTMLInputElement).value).toBe(""); + }); + + it("fetches and displays suggestions on input focus and typing", async () => { + const fetchMock = vi.fn((url: string) => { + if (url.includes("/api/suggestions/organizations")) { + return Promise.resolve( + jsonResponse({ + organizations: [ + { login: "vercel", avatarUrl: "https://example.com/v.png", description: "Vercel Inc." }, + ], + }), + ); + } + return Promise.resolve(jsonResponse({})); + }); + vi.stubGlobal("fetch", fetchMock); + + render(); + const orgInput = screen.getByLabelText("Organization"); + + fireEvent.focus(orgInput); + fireEvent.change(orgInput, { target: { value: "ver" } }); + + await waitFor(() => { + expect(screen.getByText("Vercel Inc.")).toBeTruthy(); + }); + + // Click suggestion + fireEvent.mouseDown(screen.getByText("Vercel Inc.")); + expect((orgInput as HTMLInputElement).value).toBe("vercel"); + }); + + it("submits the form with bookmarkable URL parameters", () => { + render(); + const orgInput = screen.getByLabelText("Organization"); + const techInput = screen.getByLabelText("Technology"); + + fireEvent.change(orgInput, { target: { value: "vercel" } }); + fireEvent.change(techInput, { target: { value: "TypeScript" } }); + + fireEvent.click(screen.getByRole("button", { name: /search issues/i })); + + expect(navigation.push).toHaveBeenCalledWith( + expect.stringContaining("org=vercel"), + expect.anything(), + ); + expect(navigation.push).toHaveBeenCalledWith( + expect.stringContaining("tech=TypeScript"), + expect.anything(), + ); + }); + + it("executes search when query parameters are present and displays results", async () => { + navigation.query = "org=vercel&tech=TypeScript"; + + const fetchMock = vi.fn((url: string) => { + if (url.includes("/api/organizations/issues")) { + return Promise.resolve(jsonResponse(searchResult)); + } + return Promise.resolve(jsonResponse({})); + }); + vi.stubGlobal("fetch", fetchMock); + + render(); + + await waitFor(() => { + expect( + screen.getByText("Hydration mismatch in client component"), + ).toBeTruthy(); + }); + expect(screen.getByText("vercel/next.js #101")).toBeTruthy(); + expect(screen.getAllByText("Open").length).toBeGreaterThanOrEqual(2); + }); + + it("navigates suggestions via keyboard and handles clearing", async () => { + const fetchMock = vi.fn().mockResolvedValue( + jsonResponse({ + organizations: [ + { login: "alpha", avatarUrl: "", description: "Alpha Org" }, + { login: "beta", avatarUrl: "", description: "Beta Org" }, + ], + }), + ); + vi.stubGlobal("fetch", fetchMock); + + render(); + const orgInput = screen.getByLabelText("Organization"); + + fireEvent.focus(orgInput); + fireEvent.change(orgInput, { target: { value: "a" } }); + + await waitFor(() => { + expect(screen.getByText("Alpha Org")).toBeTruthy(); + }); + + // ArrowDown, ArrowDown, ArrowUp + fireEvent.keyDown(orgInput, { key: "ArrowDown" }); + fireEvent.keyDown(orgInput, { key: "ArrowDown" }); + fireEvent.keyDown(orgInput, { key: "ArrowUp" }); + // Press Enter to select + fireEvent.keyDown(orgInput, { key: "Enter" }); + expect((orgInput as HTMLInputElement).value).toBe("alpha"); + + // Clear button + const clearButton = screen.getByLabelText("Clear input"); + fireEvent.click(clearButton); + expect((orgInput as HTMLInputElement).value).toBe(""); + }); + + it("handles empty results and pagination controls", async () => { + navigation.query = "org=empty&tech=TypeScript&page=2"; + + const fetchMock = vi.fn((url: string) => { + if (url.includes("/api/organizations/issues")) { + return Promise.resolve( + jsonResponse({ + issues: [], + totalCount: 0, + page: 2, + hasMore: false, + query: "is:issue", + notices: ["Notice test"], + tokenConfigured: true, + }), + ); + } + return Promise.resolve(jsonResponse({})); + }); + vi.stubGlobal("fetch", fetchMock); + + render(); + + await waitFor(() => { + expect(screen.getByText("No issues found.")).toBeTruthy(); + }); + expect(screen.getByText("Notice test")).toBeTruthy(); + + // Previous page button + const prevBtn = screen.getByRole("button", { name: /previous/i }); + expect((prevBtn as HTMLButtonElement).disabled).toBe(false); + fireEvent.click(prevBtn); + expect(navigation.push).toHaveBeenCalledWith( + "/organizations?org=empty&tech=TypeScript", + expect.anything(), + ); + }); + + it("displays search failure and cooldown message", async () => { + navigation.query = "org=fail&tech=TypeScript"; + + const fetchMock = vi.fn((url: string) => { + if (url.includes("/api/organizations/issues")) { + return Promise.resolve( + new Response(JSON.stringify({ error: "Rate limit exceeded" }), { + status: 429, + headers: { "Retry-After": "30" }, + }), + ); + } + return Promise.resolve(jsonResponse({})); + }); + vi.stubGlobal("fetch", fetchMock); + + render(); + + await waitFor(() => { + expect(screen.getByText("Rate limit exceeded")).toBeTruthy(); + }); + expect(screen.getByText(/Retry enabled in 30s/)).toBeTruthy(); + }); + + it("handles tech and repo auto-suggestions, dropdown changes, and next page", async () => { + const fetchMock = vi.fn((url: string) => { + if (url.includes("/api/suggestions/technologies")) { + return Promise.resolve( + jsonResponse({ + technologies: [{ name: "React", type: "framework" }], + }), + ); + } + if (url.includes("/api/suggestions/repositories")) { + return Promise.resolve( + jsonResponse({ + repositories: [ + { + name: "next.js", + fullName: "vercel/next.js", + stars: 120000, + description: "The React Framework", + }, + ], + }), + ); + } + if (url.includes("/api/organizations/issues")) { + return Promise.resolve( + jsonResponse({ + issues: [sampleIssue], + totalCount: 50, + page: 1, + hasMore: true, + query: "is:issue", + notices: [], + tokenConfigured: true, + }), + ); + } + return Promise.resolve(jsonResponse({})); + }); + vi.stubGlobal("fetch", fetchMock); + + render(); + const orgInput = screen.getByLabelText("Organization"); + const techInput = screen.getByLabelText("Technology"); + const repoInput = screen.getByLabelText("Repository (optional)"); + + // Fill org + fireEvent.change(orgInput, { target: { value: "vercel" } }); + + // Focus and select tech + fireEvent.focus(techInput); + fireEvent.change(techInput, { target: { value: "reac" } }); + await waitFor(() => { + expect(screen.getByText("React")).toBeTruthy(); + }); + expect(screen.getByText("framework")).toBeTruthy(); + fireEvent.mouseDown(screen.getByText("React")); + expect((techInput as HTMLInputElement).value).toBe("React"); + + // Focus and select repo + fireEvent.focus(repoInput); + fireEvent.change(repoInput, { target: { value: "next" } }); + await waitFor(() => { + expect(screen.getByText("The React Framework")).toBeTruthy(); + }); + fireEvent.mouseDown(screen.getByText("The React Framework")); + expect((repoInput as HTMLInputElement).value).toBe("next.js"); + + // Change status and sort + const statusSelect = screen.getByLabelText("Status"); + fireEvent.change(statusSelect, { target: { value: "closed" } }); + const sortSelect = screen.getByLabelText("Sort by"); + fireEvent.change(sortSelect, { target: { value: "comments" } }); + + // Submit form + fireEvent.click(screen.getByRole("button", { name: /search issues/i })); + expect(navigation.push).toHaveBeenCalledWith( + expect.stringContaining("status=closed"), + expect.anything(), + ); + }); + + it("handles pagination next and repeated search submission", async () => { + navigation.query = "org=vercel&tech=React"; + + const fetchMock = vi.fn().mockResolvedValue( + jsonResponse({ + issues: [sampleIssue], + totalCount: 50, + page: 1, + hasMore: true, + query: "is:issue", + notices: [], + tokenConfigured: true, + }), + ); + vi.stubGlobal("fetch", fetchMock); + + render(); + + await waitFor(() => { + expect(screen.getByText("Next")).toBeTruthy(); + }); + + // Next page button + const nextBtn = screen.getByRole("button", { name: /next/i }); + expect((nextBtn as HTMLButtonElement).disabled).toBe(false); + fireEvent.click(nextBtn); + expect(navigation.push).toHaveBeenCalledWith( + expect.stringContaining("page=2"), + expect.anything(), + ); + + // Repeated submit when query params are identical triggers re-attempt + fireEvent.click(screen.getByRole("button", { name: /search issues/i })); + }); + + it("handles validation error from URL parameters", async () => { + navigation.query = "org=invalid+org!&tech=TypeScript"; + render(); + await waitFor(() => { + expect( + screen.getByText(/Organization name may contain up to 39 alphanumeric characters/), + ).toBeTruthy(); + }); + }); + + it("handles validation error on form submission", () => { + render(); + const orgInput = screen.getByLabelText("Organization"); + const techInput = screen.getByLabelText("Technology"); + fireEvent.change(orgInput, { target: { value: "invalid org name!" } }); + fireEvent.change(techInput, { target: { value: "TypeScript" } }); + + fireEvent.click(screen.getByRole("button", { name: /search issues/i })); + expect( + screen.getByText(/Organization name may contain up to 39 alphanumeric characters/), + ).toBeTruthy(); + expect(navigation.push).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/features/organizations/filters.test.ts b/tests/features/organizations/filters.test.ts new file mode 100644 index 0000000..66b2e92 --- /dev/null +++ b/tests/features/organizations/filters.test.ts @@ -0,0 +1,144 @@ +import { describe, expect, it } from "vitest"; +import { + DEFAULT_ORGANIZATION_FILTERS, + organizationSearchParams, + readOrganizationFilters, + validateOrganizationFilters, +} from "@/features/organizations/filters"; + +describe("organization filters", () => { + it("reads defaults when parameters are absent or invalid", () => { + const filters = readOrganizationFilters(new URLSearchParams("")); + expect(filters).toEqual(DEFAULT_ORGANIZATION_FILTERS); + + const invalid = readOrganizationFilters( + new URLSearchParams("status=bogus&sort=random&page=-5"), + ); + expect(invalid.status).toBe("open"); + expect(invalid.sort).toBe("updated"); + expect(invalid.page).toBe(1); + }); + + it("reads valid search parameters", () => { + const params = new URLSearchParams( + "org=vercel&tech=TypeScript&repository=vercel/next.js&status=closed&sort=comments&page=3", + ); + const filters = readOrganizationFilters(params); + expect(filters).toEqual({ + org: "vercel", + tech: "TypeScript", + repository: "vercel/next.js", + status: "closed", + sort: "comments", + page: 3, + }); + }); + + it("validates required fields and formats", () => { + expect( + validateOrganizationFilters({ + ...DEFAULT_ORGANIZATION_FILTERS, + org: "", + tech: "React", + }), + ).toBe("Organization is required."); + + expect( + validateOrganizationFilters({ + ...DEFAULT_ORGANIZATION_FILTERS, + org: "invalid org name!", + tech: "React", + }), + ).toContain("alphanumeric characters or hyphens"); + + expect( + validateOrganizationFilters({ + ...DEFAULT_ORGANIZATION_FILTERS, + org: "vercel", + tech: "a".repeat(81), + }), + ).toBe("Technology must be 80 characters or fewer."); + + expect( + validateOrganizationFilters({ + ...DEFAULT_ORGANIZATION_FILTERS, + org: "vercel", + tech: "React", + repository: "a".repeat(141), + }), + ).toBe("Repository name must be 140 characters or fewer."); + + expect( + validateOrganizationFilters({ + ...DEFAULT_ORGANIZATION_FILTERS, + org: "vercel", + tech: "React", + repository: "invalid repo!?", + }), + ).toBe("Repository name is invalid."); + + expect( + validateOrganizationFilters({ + ...DEFAULT_ORGANIZATION_FILTERS, + org: "vercel", + tech: "React", + status: "unsupported" as any, + }), + ).toBe("Unsupported status filter."); + + expect( + validateOrganizationFilters({ + ...DEFAULT_ORGANIZATION_FILTERS, + org: "vercel", + tech: "React", + sort: "unsupported" as any, + }), + ).toBe("Unsupported sort filter."); + + expect( + validateOrganizationFilters({ + ...DEFAULT_ORGANIZATION_FILTERS, + org: "vercel", + tech: "", + }), + ).toBe("Technology is required."); + + expect( + validateOrganizationFilters({ + ...DEFAULT_ORGANIZATION_FILTERS, + org: "vercel", + tech: "React", + repository: "other-org/some-repo", + }), + ).toBe('Repository must belong to the "vercel" organization.'); + + expect( + validateOrganizationFilters({ + ...DEFAULT_ORGANIZATION_FILTERS, + org: "vercel", + tech: "React", + repository: "vercel/next.js", + }), + ).toBeNull(); + }); + + it("serializes filters to URLSearchParams", () => { + const params = organizationSearchParams({ + org: "facebook", + tech: "React", + repository: "react", + status: "closed", + sort: "created", + page: 2, + }); + expect(params.get("org")).toBe("facebook"); + expect(params.get("tech")).toBe("React"); + expect(params.get("repository")).toBe("react"); + expect(params.get("status")).toBe("closed"); + expect(params.get("sort")).toBe("created"); + expect(params.get("page")).toBe("2"); + + const defaultParams = organizationSearchParams(DEFAULT_ORGANIZATION_FILTERS); + expect(defaultParams.toString()).toBe(""); + }); +}); diff --git a/tests/features/organizations/issue-card.test.tsx b/tests/features/organizations/issue-card.test.tsx new file mode 100644 index 0000000..c4223c0 --- /dev/null +++ b/tests/features/organizations/issue-card.test.tsx @@ -0,0 +1,51 @@ +// @vitest-environment jsdom +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { OrganizationIssueCard } from "@/features/organizations/components/organization-issue-card"; +import type { OrganizationIssue } from "@/features/organizations/types"; + +describe("OrganizationIssueCard", () => { + it("renders closed issue without labels or author avatar", () => { + const closedIssue: OrganizationIssue = { + id: "I_closed", + number: 99, + title: "Closed bug report", + url: "https://github.com/facebook/react/issues/99", + repository: "facebook/react", + author: "bob", + status: "closed", + labels: [], + comments: 0, + createdAt: "2026-08-01T00:00:00Z", + updatedAt: "2026-08-02T00:00:00Z", + }; + + render(); + expect(screen.getByText("facebook/react #99")).toBeTruthy(); + expect(screen.getByText("Closed bug report")).toBeTruthy(); + expect(screen.getByText("Closed")).toBeTruthy(); + expect(screen.getByText("bob")).toBeTruthy(); + }); + + it("renders open issue with author avatar and labels", () => { + const openIssue: OrganizationIssue = { + id: "I_open", + number: 100, + title: "Open feature request", + url: "https://github.com/facebook/react/issues/100", + repository: "facebook/react", + author: "alice", + authorAvatarUrl: "https://example.com/alice.png", + status: "open", + labels: [{ name: "enhancement", color: "a2eeef" }], + comments: 3, + createdAt: "2026-08-01T00:00:00Z", + updatedAt: "2026-08-02T00:00:00Z", + }; + + render(); + expect(screen.getByText("facebook/react #100")).toBeTruthy(); + expect(screen.getByText("Open feature request")).toBeTruthy(); + expect(screen.getByText("enhancement")).toBeTruthy(); + }); +}); diff --git a/tests/features/organizations/search.test.ts b/tests/features/organizations/search.test.ts new file mode 100644 index 0000000..c04ae7e --- /dev/null +++ b/tests/features/organizations/search.test.ts @@ -0,0 +1,354 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + buildOrganizationIssueQueries, + searchOrganizationIssues, +} from "@/features/organizations/server/search"; +import type { OrganizationIssueFilters } from "@/features/organizations/types"; + +afterEach(() => vi.unstubAllGlobals()); + +describe("buildOrganizationIssueQueries", () => { + it("builds language query scoped to organization", async () => { + const filters: OrganizationIssueFilters = { + org: "facebook", + tech: "JavaScript", + repository: "", + status: "open", + sort: "updated", + page: 1, + }; + const { queries } = await buildOrganizationIssueQueries(filters); + expect(queries).toEqual([ + 'is:issue is:public archived:false is:open org:facebook language:"JavaScript"', + ]); + }); + + it("builds query scoped to specific repository if provided", async () => { + const filters: OrganizationIssueFilters = { + org: "vercel", + tech: "TypeScript", + repository: "next.js", + status: "open", + sort: "updated", + page: 1, + }; + const { queries } = await buildOrganizationIssueQueries(filters); + expect(queries).toEqual([ + 'is:issue is:public archived:false is:open repo:vercel/next.js language:"TypeScript"', + ]); + }); + + it("supports closed and all status queries", async () => { + const closedFilters: OrganizationIssueFilters = { + org: "vercel", + tech: "TypeScript", + repository: "", + status: "closed", + sort: "updated", + page: 1, + }; + const { queries: closedQueries } = + await buildOrganizationIssueQueries(closedFilters); + expect(closedQueries[0]).toContain("is:closed"); + + const allFilters: OrganizationIssueFilters = { + org: "vercel", + tech: "TypeScript", + repository: "", + status: "all", + sort: "updated", + page: 1, + }; + const { queries: allQueries } = + await buildOrganizationIssueQueries(allFilters); + expect(allQueries[0]).not.toContain("is:open"); + expect(allQueries[0]).not.toContain("is:closed"); + }); + + it("discovers repositories by topic for framework technologies", async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + items: [ + { full_name: "facebook/react", private: false }, + { full_name: "facebook/react-native", private: false }, + ], + total_count: 2, + }), + ), + ); + vi.stubGlobal("fetch", fetchMock); + + const filters: OrganizationIssueFilters = { + org: "facebook", + tech: "React", + repository: "", + status: "open", + sort: "updated", + page: 1, + }; + const { queries, notices } = + await buildOrganizationIssueQueries(filters); + expect(queries[0]).toContain("repo:facebook/react"); + expect(queries[0]).toContain("repo:facebook/react-native"); + expect(notices.some((n) => n.includes("topic"))).toBe(true); + }); +}); + +describe("searchOrganizationIssues", () => { + it("searches and maps GitHub issues with pagination", async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + total_count: 1, + items: [ + { + node_id: "I_kwDOA123", + number: 42, + title: "Bug in server rendering", + html_url: "https://github.com/vercel/next.js/issues/42", + repository_url: "https://api.github.com/repos/vercel/next.js", + user: { + login: "alice", + avatar_url: "https://avatars.githubusercontent.com/u/1", + }, + state: "open", + labels: [{ name: "bug", color: "d73a4a" }], + comments: 5, + created_at: "2026-09-01T00:00:00Z", + updated_at: "2026-09-02T00:00:00Z", + }, + ], + }), + ), + ); + vi.stubGlobal("fetch", fetchMock); + + const filters: OrganizationIssueFilters = { + org: "vercel", + tech: "TypeScript", + repository: "next.js", + status: "open", + sort: "updated", + page: 1, + }; + + const response = await searchOrganizationIssues(filters); + expect(response.totalCount).toBe(1); + expect(response.issues).toHaveLength(1); + expect(response.issues[0]).toEqual({ + id: "I_kwDOA123", + number: 42, + title: "Bug in server rendering", + url: "https://github.com/vercel/next.js/issues/42", + repository: "vercel/next.js", + author: "alice", + authorAvatarUrl: "https://avatars.githubusercontent.com/u/1", + status: "open", + labels: [{ name: "bug", color: "d73a4a" }], + comments: 5, + createdAt: "2026-09-01T00:00:00Z", + updatedAt: "2026-09-02T00:00:00Z", + }); + }); + + it("handles empty repository matches for topic search gracefully", async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ items: [], total_count: 0 })), + ); + vi.stubGlobal("fetch", fetchMock); + + const filters: OrganizationIssueFilters = { + org: "unknown-org", + tech: "React", + repository: "", + status: "open", + sort: "updated", + page: 1, + }; + + const response = await searchOrganizationIssues(filters); + expect(response.issues).toEqual([]); + expect(response.totalCount).toBe(0); + expect(response.notices[0]).toContain("No repositories in organization"); + }); + + it("sorts results by comments and created date", async () => { + const fetchMock = vi.fn(() => + Promise.resolve( + new Response( + JSON.stringify({ + total_count: 2, + items: [ + { + node_id: "I_1", + number: 1, + title: "Old issue with more comments", + html_url: "https://github.com/v/r/issues/1", + repository_url: "https://api.github.com/repos/v/r", + user: null, + state: "open", + labels: [], + comments: 20, + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", + }, + { + node_id: "I_2", + number: 2, + title: "New issue with fewer comments", + html_url: "https://github.com/v/r/issues/2", + repository_url: "https://api.github.com/repos/v/r", + user: null, + state: "open", + labels: [], + comments: 2, + created_at: "2026-09-01T00:00:00Z", + updated_at: "2026-09-01T00:00:00Z", + }, + ], + }), + ), + ), + ); + vi.stubGlobal("fetch", fetchMock); + + const commentSorted = await searchOrganizationIssues({ + org: "v", + tech: "TypeScript", + repository: "", + status: "open", + sort: "comments", + page: 1, + }); + expect(commentSorted.issues[0].comments).toBe(20); + + const createdSorted = await searchOrganizationIssues({ + org: "v", + tech: "TypeScript", + repository: "", + status: "open", + sort: "created", + page: 1, + }); + expect(createdSorted.issues[0].id).toBe("I_2"); + }); + + it("handles framework search with an exact repository", async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + full_name: "facebook/react", + private: false, + archived: false, + topics: ["react"], + }), + ), + ); + vi.stubGlobal("fetch", fetchMock); + + const { queries } = await buildOrganizationIssueQueries({ + org: "facebook", + tech: "React", + repository: "react", + status: "open", + sort: "updated", + page: 1, + }); + expect(queries[0]).toContain("repo:facebook/react"); + }); + + it("handles incomplete repository discovery and caps with notices", async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + items: Array.from({ length: 20 }, (_, i) => ({ + full_name: `facebook/repo-${i}`, + private: false, + })), + total_count: 50, + incomplete_results: true, + }), + ), + ); + vi.stubGlobal("fetch", fetchMock); + + const { queries, notices } = await buildOrganizationIssueQueries({ + org: "facebook", + tech: "React", + repository: "", + status: "open", + sort: "updated", + page: 1, + }); + expect(queries.length).toBeGreaterThan(1); + expect(notices.some((n) => n.includes("50"))).toBe(true); + expect(notices.some((n) => n.includes("incomplete"))).toBe(true); + }); + + it("handles failure when fetching exact framework repository", async () => { + const fetchMock = vi.fn().mockRejectedValue(new Error("Repo not found")); + vi.stubGlobal("fetch", fetchMock); + + const { queries } = await buildOrganizationIssueQueries({ + org: "facebook", + tech: "React", + repository: "nonexistent", + status: "open", + sort: "updated", + page: 1, + }); + expect(queries).toEqual([]); + }); + + it("sorts by updated date by default", async () => { + const fetchMock = vi.fn(() => + Promise.resolve( + new Response( + JSON.stringify({ + total_count: 2, + items: [ + { + node_id: "I_old", + number: 1, + title: "Older update", + html_url: "https://github.com/v/r/issues/1", + repository_url: "https://api.github.com/repos/v/r", + user: null, + state: "open", + labels: [], + comments: 0, + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-02T00:00:00Z", + }, + { + node_id: "I_new", + number: 2, + title: "Newer update", + html_url: "https://github.com/v/r/issues/2", + repository_url: "https://api.github.com/repos/v/r", + user: null, + state: "open", + labels: [], + comments: 0, + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-09-02T00:00:00Z", + }, + ], + }), + ), + ), + ); + vi.stubGlobal("fetch", fetchMock); + + const updatedSorted = await searchOrganizationIssues({ + org: "v", + tech: "TypeScript", + repository: "", + status: "open", + sort: "updated", + page: 1, + }); + expect(updatedSorted.issues[0].id).toBe("I_new"); + }); +}); diff --git a/tests/features/organizations/suggestions.test.ts b/tests/features/organizations/suggestions.test.ts new file mode 100644 index 0000000..f20561d --- /dev/null +++ b/tests/features/organizations/suggestions.test.ts @@ -0,0 +1,152 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + getOrganizationSuggestions, + getRepositorySuggestions, + getTechnologySuggestions, +} from "@/features/organizations/server/suggestions"; + +afterEach(() => vi.unstubAllGlobals()); + +describe("organization suggestions", () => { + it("returns popular organizations when query is empty", async () => { + const suggestions = await getOrganizationSuggestions(""); + expect(suggestions.length).toBeGreaterThan(0); + expect(suggestions.some((s) => s.login === "vercel")).toBe(true); + }); + + it("queries GitHub search users for organization matches", async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + items: [ + { + login: "tailwindlabs", + avatar_url: "https://avatars.githubusercontent.com/u/67104417", + description: "Tailwind CSS", + }, + ], + }), + ), + ); + vi.stubGlobal("fetch", fetchMock); + + const suggestions = await getOrganizationSuggestions("tailwind"); + expect(suggestions.some((s) => s.login === "tailwindlabs")).toBe(true); + expect(fetchMock).toHaveBeenCalled(); + }); +}); + +describe("technology suggestions", () => { + it("returns popular technologies when query is empty", () => { + const suggestions = getTechnologySuggestions(""); + expect(suggestions).toHaveLength(10); + expect(suggestions.some((s) => s.name === "TypeScript")).toBe(true); + }); + + it("filters technologies by name match", () => { + const suggestions = getTechnologySuggestions("type"); + expect(suggestions.some((s) => s.name === "TypeScript")).toBe(true); + + const python = getTechnologySuggestions("pyth"); + expect(python[0].name).toBe("Python"); + }); +}); + +describe("repository suggestions", () => { + it("returns empty array when org is empty", async () => { + const suggestions = await getRepositorySuggestions(""); + expect(suggestions).toEqual([]); + }); + + it("queries GitHub search repositories scoped to the organization", async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + items: [ + { + name: "next.js", + full_name: "vercel/next.js", + stargazers_count: 120000, + description: "The React Framework", + private: false, + }, + ], + }), + ), + ); + vi.stubGlobal("fetch", fetchMock); + + const suggestions = await getRepositorySuggestions("vercel", "next"); + expect(suggestions).toHaveLength(1); + expect(suggestions[0].name).toBe("next.js"); + expect(suggestions[0].fullName).toBe("vercel/next.js"); + expect(suggestions[0].stars).toBe(120000); + + const calledUrl = fetchMock.mock.calls[0][0] as string; + expect(calledUrl).toContain("org%3Avercel"); + }); + + it("handles rate limits and errors in suggestions", async () => { + const { RateLimitError } = await import("@/lib/github"); + const rateLimitMock = vi.fn().mockRejectedValue(new RateLimitError("Rate limit", 60)); + vi.stubGlobal("fetch", rateLimitMock); + + await expect(getOrganizationSuggestions("test")).rejects.toThrow("Rate limit"); + await expect(getRepositorySuggestions("test", "query")).rejects.toThrow("Rate limit"); + + // General network failure falls back gracefully + const networkFailMock = vi.fn().mockRejectedValue(new Error("Network fail")); + vi.stubGlobal("fetch", networkFailMock); + + const fallbackOrgs = await getOrganizationSuggestions("ver"); + expect(fallbackOrgs.some((o) => o.login === "vercel")).toBe(true); + + const fallbackRepos = await getRepositorySuggestions("test", "query"); + expect(fallbackRepos).toEqual([]); + }); + + it("handles empty query in repository suggestions and caps organization and technology suggestions", async () => { + // 1. empty query in getRepositorySuggestions + const fetchMock = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + items: [ + { + name: "turbo", + full_name: "vercel/turbo", + stargazers_count: 25000, + description: null, + private: false, + }, + ], + }), + ), + ); + vi.stubGlobal("fetch", fetchMock); + + const repos = await getRepositorySuggestions("vercel"); + expect(repos).toHaveLength(1); + expect(fetchMock.mock.calls[0][0]).toContain("org%3Avercel%20archived%3Afalse"); + + // 2. organization suggestions with 10 items and null description + const orgFetchMock = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + items: Array.from({ length: 10 }, (_, i) => ({ + login: `org-${i}`, + avatar_url: `https://example.com/${i}.png`, + description: null, + })), + }), + ), + ); + vi.stubGlobal("fetch", orgFetchMock); + + const orgs = await getOrganizationSuggestions("org"); + expect(orgs).toHaveLength(8); + + // 3. technology suggestions capped at 10 from popular and linguist + const techMany = getTechnologySuggestions("c"); + expect(techMany).toHaveLength(10); + }); +}); diff --git a/tests/lib/github.test.ts b/tests/lib/github.test.ts new file mode 100644 index 0000000..d69e488 --- /dev/null +++ b/tests/lib/github.test.ts @@ -0,0 +1,96 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { githubFetch } from "@/lib/github"; + +afterEach(() => vi.unstubAllGlobals()); + +describe("GitHub request boundary", () => { + it.each([ + "/search/issues", "/search/repositories", "/search/users", "/repos/acme/widgets", + "/repos/acme/.github/community/profile", "/repos/acme/widgets/issues/42/comments", + "/repos/acme/widgets/issues/42/timeline", "/repos/acme/widgets/pulls/42", + ])("allows supported endpoint %s", async (path) => { + const fetch = vi.fn().mockResolvedValue(new Response("{}")); + vi.stubGlobal("fetch", fetch); + await githubFetch("https://api.github.com" + path); + expect(fetch).toHaveBeenCalledTimes(1); + }); + it.each([ + "/repos/acme/widgets/pulls/0", "/repos/acme/widgets/pulls/-1", + "/repos/acme/widgets/issues/42/comments/extra", "/repos/acme/widgets/", + "/repos/acme/", "/repos/acme/widgets/issues/0/timeline", + "/repos/acme/widgets!/community/profile", "/repos/acme!/widgets", + "/repos//widgets", "/repos/acme/widgets//community/profile", + ])("rejects invalid repository endpoint %s", async (path) => { + const fetch = vi.fn(); + vi.stubGlobal("fetch", fetch); + await expect(githubFetch("https://api.github.com" + path)).rejects.toThrow("Unsupported GitHub API endpoint."); + expect(fetch).not.toHaveBeenCalled(); + }); + it.each([ + "https://api.github.com/user/emails", + "https://api.github.com/repos/acme/widgets/contents/.env", + "https://api.github.com//attacker.example/path", + "https://api.github.com/repos/acme/widgets/issues/1/../../../../user", + "https://api.github.com/repos/acme/widgets%2F..%2Fprivate", + ])("rejects unintended GitHub paths %s", async (url) => { + const fetch = vi.fn(); + vi.stubGlobal("fetch", fetch); + await expect(githubFetch(url, "test-token")).rejects.toThrow("Unsupported GitHub API endpoint."); + expect(fetch).not.toHaveBeenCalled(); + }); + it("keeps malicious-looking filter text within encoded query values", async () => { + const fetch = vi.fn().mockResolvedValue(new Response("{}")); + vi.stubGlobal("fetch", fetch); + const query = 'is:pr https://attacker.example/a?x=1&y=2 #fragment'; + const url = new URL("https://api.github.com/search/issues"); + url.searchParams.set("q", query); + await githubFetch(url.toString()); + const destination = new URL(fetch.mock.calls[0][0]); + expect(destination.origin).toBe("https://api.github.com"); + expect(destination.pathname).toBe("/search/issues"); + expect([...destination.searchParams]).toEqual([["q", query]]); + expect(destination.hash).toBe(""); + }); + it.each([ + "http://api.github.com/repos/acme/widgets", + "https://api.github.com.attacker.example/repos/acme/widgets", + "https://api.github.com@attacker.example/repos/acme/widgets", + "https://127.0.0.1/admin", + "https://api.github.com:8443/repos/acme/widgets", + "https://user:password@api.github.com/repos/acme/widgets", + "file:///etc/passwd", + ])( + "rejects unsafe destination %s before sending credentials", + async (url) => { + const fetch = vi.fn(); + vi.stubGlobal("fetch", fetch); + await expect(githubFetch(url, "test-token")).rejects.toThrow( + "Only the GitHub HTTPS API is allowed.", + ); + expect(fetch).not.toHaveBeenCalled(); + }, + ); + it("uses the validated GitHub origin and refuses redirects", async () => { + const fetch = vi + .fn() + .mockResolvedValue(new Response(JSON.stringify({ ok: true }))); + vi.stubGlobal("fetch", fetch); + expect( + ( + await githubFetch( + "https://api.github.com/search/issues?q=is%3Apr", + "test-token", + ) + ).data, + ).toEqual({ ok: true }); + expect(fetch).toHaveBeenCalledWith( + "https://api.github.com/search/issues?q=is%3Apr", + expect.objectContaining({ + redirect: "error", + headers: expect.objectContaining({ + Authorization: "Bearer test-token", + }), + }), + ); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index 65560f8..01a5e23 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -19,6 +19,8 @@ export default defineConfig({ "src/components/ui/**", "src/app/layout.tsx", "src/app/page.tsx", + "src/app/organizations/page.tsx", + "src/app/pull-requests/page.tsx", "src/components/theme-provider.tsx", "src/app/api/auth/**", "src/lib/auth-schema.ts", @@ -26,6 +28,7 @@ export default defineConfig({ "src/lib/auth-client.ts", "src/features/issues/types/**", "src/features/issues/data/**", + "src/features/organizations/types.ts", ], thresholds: { statements: 95,