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 (
+
+ {[
+ { href: "/", value: "issues", label: "By Technology", Icon: CircleDot },
+ {
+ href: "/organizations",
+ value: "organizations",
+ label: "By Organization",
+ Icon: Building2,
+ },
+ ].map(({ href, value, label, Icon }) => (
+
+
+ {label}
+
+ ))}
+
+ );
+}
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 (
+
+ {
+ // Prevent blur before selection
+ e.preventDefault();
+ handleSelect(item);
+ }}
+ onMouseEnter={() => setHighlightedIndex(index)}
+ className={cn(
+ "w-full text-left cursor-pointer select-none rounded-md px-2.5 py-1.5 text-sm transition-colors",
+ isHighlighted
+ ? "bg-accent text-accent-foreground"
+ : "hover:bg-muted/60",
+ )}
+ >
+ {renderSuggestion(item, isHighlighted)}
+
+
+ );
+ });
+ }
+
+ 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 (
+ <>
+
+
+
+ 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) => (
+
+ ))}
+
+ )}
+
+
+ navigate({ ...filters, page: data.page - 1 })}
+ >
+ Previous
+
+
+ Page {data.page}
+
+ navigate({ ...filters, page: data.page + 1 })}
+ >
+ Next
+
+
+
+ )}
+
+ {!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 (
+
+ );
+}
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 ? (
+
+ ) : 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 (
+ onValueChange?.(e.target.value)}
+ className={selectTrigger?.props?.className}
+ >
+ {selectItems.map((item: any, index: number) => (
+
+ {item.props?.children}
+
+ ))}
+
+ );
+ },
+ SelectTrigger: ({ children }: any) => children,
+ SelectValue: ({ children }: any) => children,
+ SelectContent: ({ children }: any) => children,
+ SelectItem: ({ value, children }: any) => (
+ {children}
+ ),
+}));
+
+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,