Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions doc/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
42 changes: 42 additions & 0 deletions src/app/api/organizations/issues/route.ts
Original file line number Diff line number Diff line change
@@ -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 },
);
}
}
28 changes: 28 additions & 0 deletions src/app/api/suggestions/organizations/route.ts
Original file line number Diff line number Diff line change
@@ -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 },
);
}
}
36 changes: 36 additions & 0 deletions src/app/api/suggestions/repositories/route.ts
Original file line number Diff line number Diff line change
@@ -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 },
);
}
}
10 changes: 10 additions & 0 deletions src/app/api/suggestions/technologies/route.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
23 changes: 23 additions & 0 deletions src/app/organizations/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Suspense
fallback={
<main className="p-8">
<output>Loading organization dashboard…</output>
</main>
}
>
<OrganizationDashboard />
</Suspense>
);
}
5 changes: 5 additions & 0 deletions src/app/pull-requests/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { redirect } from "next/navigation";

export default function PullRequestsRedirect() {
redirect("/organizations");
}
39 changes: 39 additions & 0 deletions src/components/dashboard-navigation.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<nav
aria-label="Dashboards"
className="flex w-fit items-center gap-1 rounded-lg border bg-background p-1 text-sm"
>
{[
{ href: "/", value: "issues", label: "By Technology", Icon: CircleDot },
{
href: "/organizations",
value: "organizations",
label: "By Organization",
Icon: Building2,
},
].map(({ href, value, label, Icon }) => (
<Link
key={value}
href={href}
aria-current={activeTab === value ? "page" : undefined}
className={cn(
"inline-flex items-center gap-2 rounded-md px-3 py-2 hover:bg-muted",
activeTab === value && "bg-muted font-medium",
)}
>
<Icon className="size-4" />
{label}
</Link>
))}
</nav>
);
}
12 changes: 2 additions & 10 deletions src/features/issues/components/issue-finder.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -1115,7 +1111,6 @@ function SearchOverview({
scope,
responsiveness,
data,
tokenStatus,
}: Readonly<{
label: string;
sort: string;
Expand All @@ -1126,7 +1121,6 @@ function SearchOverview({
scope: string;
responsiveness: string;
data: SearchResponse | null;
tokenStatus: string;
}>) {
return (
<Card className="self-end">
Expand All @@ -1151,7 +1145,6 @@ function SearchOverview({
label="Raw GitHub matches"
value={data ? compactNumber(data.totalCount) : "-"}
/>
<Metric label="GitHub token" value={tokenStatus} />
</CardContent>
</Card>
);
Expand Down Expand Up @@ -1747,7 +1740,6 @@ export function IssueFinder() {
}
}

const tokenStatus = getTokenStatus(data);
const handleRetry = getRetryHandler(
errorSource,
() => void searchIssues(),
Expand Down Expand Up @@ -1784,6 +1776,7 @@ export function IssueFinder() {
<ThemeToggle />
</div>
</div>
<DashboardNavigation current="issues" />
<div className="max-w-3xl space-y-4">
<h1 className="text-4xl font-semibold tracking-normal text-foreground sm:text-5xl">
Find active open-source issues by tech.
Expand Down Expand Up @@ -1981,7 +1974,6 @@ export function IssueFinder() {
scope={selectedScope.label}
responsiveness={selectedResponsiveness.label}
data={data}
tokenStatus={tokenStatus}
/>
</div>
</section>
Expand Down
Loading