From 2fbf7b81553931b55d40c80842569d1e7e983811 Mon Sep 17 00:00:00 2001 From: Rodrigo Matos Date: Wed, 26 Aug 2026 16:55:23 -0300 Subject: [PATCH 1/2] feat(platform): add stale + archived filters to the repos page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two filters on /[tenant]/repos, requested as the dashboard equivalent of scripts/list_active_repos.py's --stale/--include-archived (which only ever touched that standalone CLI script, not the real product). - Stale (no push in 90d+): pure client-side filter on RepoList using the last_run_at the page already loads via getOrgReposSummary — no new data, no new query. Same "never pushed also counts as stale" semantics as the CLI script, and the same documented caveat: this is last iris push time, not literal last-commit time (no per-commit date survives into the stored payload). - Archived (GitHub status): the platform has no stored column for this and no durable GitHub credential to background-sync it — only a session-scoped OAuth token with no `repo` scope (public repos only). Per explicit direction, this ships with no database changes: a new "Check archived"/"Hide archived" toggle triggers an on-demand, ephemeral POST to /api/repos/check-archived, which calls GitHub's REST API in parallel per repo (mirroring the existing listUserOrgs/listOrgMembers pattern in lib/github.ts) using the logged-in user's own token, and returns results that live only in component state — nothing persisted, nothing runs automatically on page load. Private repos (403/404) come back "unknown", not archived. The button is disabled with an explanatory tooltip when the session has no GitHub-linked token. Reuses normalizeRepoSlug (already exported from lib/integrations/datadog/sync.ts, already tested) to match a repo's remote_url back to the API response. Co-Authored-By: claude-code_2-1-238_agent --- platform/lib/github.ts | 87 ++++++++++--- platform/lib/translations.ts | 13 ++ .../src/app/[tenant]/dashboard/repo-list.tsx | 115 +++++++++++++++++- platform/src/app/[tenant]/repos/page.tsx | 1 + .../src/app/api/repos/check-archived/route.ts | 38 ++++++ 5 files changed, 233 insertions(+), 21 deletions(-) create mode 100644 platform/src/app/api/repos/check-archived/route.ts diff --git a/platform/lib/github.ts b/platform/lib/github.ts index e09252a..8194a66 100644 --- a/platform/lib/github.ts +++ b/platform/lib/github.ts @@ -3,7 +3,9 @@ * Tokens come from the user's NextAuth session. */ -const API = 'https://api.github.com'; +import { normalizeRepoSlug } from "./integrations/datadog/sync"; + +const API = "https://api.github.com"; export interface GitHubOrgSummary { id: number; @@ -28,15 +30,17 @@ async function call(path: string, accessToken: string): Promise { const res = await fetch(`${API}${path}`, { headers: { Authorization: `Bearer ${accessToken}`, - Accept: 'application/vnd.github+json', - 'X-GitHub-Api-Version': '2022-11-28', - 'User-Agent': 'iris-platform', + Accept: "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + "User-Agent": "iris-platform", }, - cache: 'no-store', + cache: "no-store", }); if (!res.ok) { - const body = await res.text().catch(() => ''); - throw new Error(`GitHub API ${res.status} on ${path}: ${body.slice(0, 200)}`); + const body = await res.text().catch(() => ""); + throw new Error( + `GitHub API ${res.status} on ${path}: ${body.slice(0, 200)}`, + ); } return (await res.json()) as T; } @@ -61,7 +65,7 @@ interface RawUserDetail { function parseLinkHeader(header: string | null): Record { if (!header) return {}; const links: Record = {}; - for (const part of header.split(',')) { + for (const part of header.split(",")) { const match = part.match(/<([^>]+)>;\s*rel="([^"]+)"/); if (match) links[match[2]] = match[1]; } @@ -72,15 +76,17 @@ async function callRaw(url: string, accessToken: string): Promise { const res = await fetch(url, { headers: { Authorization: `Bearer ${accessToken}`, - Accept: 'application/vnd.github+json', - 'X-GitHub-Api-Version': '2022-11-28', - 'User-Agent': 'iris-platform', + Accept: "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + "User-Agent": "iris-platform", }, - cache: 'no-store', + cache: "no-store", }); if (!res.ok) { - const body = await res.text().catch(() => ''); - throw new Error(`GitHub API ${res.status} on ${url}: ${body.slice(0, 200)}`); + const body = await res.text().catch(() => ""); + throw new Error( + `GitHub API ${res.status} on ${url}: ${body.slice(0, 200)}`, + ); } return res; } @@ -101,7 +107,7 @@ export async function listOrgMembers( const res = await callRaw(url, accessToken); const page = (await res.json()) as RawMember[]; all.push(...page); - const links = parseLinkHeader(res.headers.get('link')); + const links = parseLinkHeader(res.headers.get("link")); url = links.next ?? null; } @@ -143,8 +149,10 @@ export async function listOrgMembers( * org access. Orgs that require SAML/SSO without an active session may be * omitted by GitHub — that's a GitHub-side gate, not something we can fix. */ -export async function listUserOrgs(accessToken: string): Promise { - const raws = await call('/user/orgs?per_page=100', accessToken); +export async function listUserOrgs( + accessToken: string, +): Promise { + const raws = await call("/user/orgs?per_page=100", accessToken); // Fill in display names with one extra call per org. We cap at 100 by API // limits anyway, and most users belong to a handful of orgs. @@ -152,7 +160,10 @@ export async function listUserOrgs(accessToken: string): Promise { let name: string | null = null; try { - const detail = await call(`/orgs/${raw.login}`, accessToken); + const detail = await call( + `/orgs/${raw.login}`, + accessToken, + ); name = detail.name; } catch { // Non-fatal: keep going with the login as the displayable name. @@ -169,3 +180,43 @@ export async function listUserOrgs(accessToken: string): Promise> { + const pathBySlug = new Map(); + for (const url of remoteUrls) { + const slug = normalizeRepoSlug(url ?? null); + if (!slug || !slug.startsWith("github.com/")) continue; + const path = slug.slice("github.com/".length); + if (path.split("/").length === 2) pathBySlug.set(slug, path); + } + + const entries = await Promise.all( + Array.from(pathBySlug.entries()).map(async ([slug, path]) => { + try { + const detail = await call(`/repos/${path}`, accessToken); + return [slug, detail.archived] as const; + } catch { + return [slug, null] as const; + } + }), + ); + + return Object.fromEntries(entries); +} diff --git a/platform/lib/translations.ts b/platform/lib/translations.ts index 0dcb52b..3b9e013 100644 --- a/platform/lib/translations.ts +++ b/platform/lib/translations.ts @@ -455,6 +455,12 @@ export const translations = { human: "Human", ai: "AI", sortByAi: "Most AI first", + staleOnly: "Stale (90d+)", + checkArchived: "Check archived", + checkingArchived: "Checking...", + hideArchived: "Hide archived", + archivedTag: "archived", + noGithubLink: "Sign in with GitHub to check archived status", }, }, investHere: { @@ -1874,6 +1880,13 @@ export const translations = { human: "Humano", ai: "IA", sortByAi: "Mais IA primeiro", + staleOnly: "Parados (90d+)", + checkArchived: "Checar arquivados", + checkingArchived: "Checando...", + hideArchived: "Ocultar arquivados", + archivedTag: "arquivado", + noGithubLink: + "Faça login com GitHub para checar status de arquivamento", }, }, investHere: { diff --git a/platform/src/app/[tenant]/dashboard/repo-list.tsx b/platform/src/app/[tenant]/dashboard/repo-list.tsx index e91f682..13399ea 100644 --- a/platform/src/app/[tenant]/dashboard/repo-list.tsx +++ b/platform/src/app/[tenant]/dashboard/repo-list.tsx @@ -4,11 +4,19 @@ import { useState } from "react"; import Link from "next/link"; -import { ArrowDownWideNarrow, Search, Trash2 } from "lucide-react"; +import { + Archive, + ArrowDownWideNarrow, + Clock, + Search, + Trash2, +} from "lucide-react"; +import { useSession } from "next-auth/react"; import { DeleteRepositoryDialog } from "@/components/repos/DeleteRepositoryDialog"; import { Button } from "@/components/ui/button"; import { useTranslation } from "@/hooks/useTranslation"; +import { normalizeRepoSlug } from "@/lib/integrations/datadog/sync"; import { cn } from "@/lib/utils"; import type { RepoSummary } from "@/types/temporal"; import { healthIndicator } from "@/types/temporal"; @@ -19,6 +27,9 @@ interface RepoListProps { organizationId?: string; canDelete?: boolean; showSearch?: boolean; + /** Server-computed timestamp (ms) — the "stale" filter's cutoff is derived + * from this instead of calling Date.now() during a client render. */ + nowMs: number; } const healthColors: Record = { @@ -28,17 +39,64 @@ const healthColors: Record = { gray: "bg-signal-gray", }; +const STALE_MS = 90 * 24 * 60 * 60 * 1000; + export function RepoList({ repos, orgSlug, organizationId, canDelete = false, showSearch = false, + nowMs, }: RepoListProps) { const { t } = useTranslation(); + const { data: session } = useSession(); const [query, setQuery] = useState(""); const [sortByAi, setSortByAi] = useState(false); + const [staleOnly, setStaleOnly] = useState(false); + const [hideArchived, setHideArchived] = useState(false); + const [checkingArchived, setCheckingArchived] = useState(false); + // Ephemeral, not persisted: repo-slug -> archived (null = unknown, e.g. + // private repo the session token can't read). Empty until "Check + // archived" runs, and re-fetched fresh every time — never cached across + // page loads. + const [archivedBySlug, setArchivedBySlug] = useState< + Record + >({}); const showDeleteColumn = canDelete && !!organizationId; + // nowMs comes from the server component (page.tsx) rather than a + // Date.now() call here — calling it directly during a client render + // isn't pure. + const staleCutoff = nowMs - STALE_MS; + const hasGithubLink = !!( + session?.user as { githubAccessToken?: string } | undefined + )?.githubAccessToken; + + async function handleCheckArchived() { + setCheckingArchived(true); + try { + const res = await fetch("/api/repos/check-archived", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ remoteUrls: repos.map((r) => r.remote_url) }), + }); + if (res.ok) { + const data = (await res.json()) as { + archived: Record; + }; + setArchivedBySlug(data.archived); + } + } catch { + // Non-fatal — the button just stays available to retry. + } finally { + setCheckingArchived(false); + } + } + + function isArchived(repo: RepoSummary): boolean | null { + const slug = normalizeRepoSlug(repo.remote_url); + return slug ? (archivedBySlug[slug] ?? null) : null; + } if (repos.length === 0) { return ( @@ -53,10 +111,20 @@ export function RepoList({ ); } - const filtered = query + let filtered = query ? repos.filter((r) => r.name.toLowerCase().includes(query.toLowerCase())) : repos; + if (staleOnly) { + filtered = filtered.filter( + (r) => !r.last_run_at || new Date(r.last_run_at).getTime() < staleCutoff, + ); + } + + if (hideArchived) { + filtered = filtered.filter((r) => isArchived(r) !== true); + } + // Repos without AI data sort to the end regardless of direction. const sorted = sortByAi ? [...filtered].sort( @@ -90,12 +158,48 @@ export function RepoList({ {t("dashboard.repoList.sortByAi")} + + )} {sorted.map((repo) => { const color = healthIndicator(repo.health); const aiPct = repo.ai_detection_coverage_pct; const humanPct = aiPct != null ? 100 - aiPct : null; + const archived = isArchived(repo); return (
-

+

{repo.name} + {archived === true && ( + + {t("dashboard.repoList.archivedTag")} + + )}

{repo.runs_count} runs diff --git a/platform/src/app/[tenant]/repos/page.tsx b/platform/src/app/[tenant]/repos/page.tsx index 0fa6142..d4a05dc 100644 --- a/platform/src/app/[tenant]/repos/page.tsx +++ b/platform/src/app/[tenant]/repos/page.tsx @@ -82,6 +82,7 @@ export default async function ReposPage({ organizationId={orgId} canDelete={canDelete} showSearch + nowMs={Date.now()} />

); diff --git a/platform/src/app/api/repos/check-archived/route.ts b/platform/src/app/api/repos/check-archived/route.ts new file mode 100644 index 0000000..147e6c5 --- /dev/null +++ b/platform/src/app/api/repos/check-archived/route.ts @@ -0,0 +1,38 @@ +import { NextRequest, NextResponse } from "next/server"; + +import { getServerSession } from "next-auth/next"; + +import { authOptions } from "@/lib/auth"; +import { logError } from "@/lib/debug"; +import { checkArchivedStatus } from "@/lib/github"; + +/** + * On-demand, ephemeral archived-status check for a set of repos — nothing + * here is persisted. Meant to be called for whatever repos are currently + * rendered on a page, not as a full-org background sync. + */ +export async function POST(request: NextRequest) { + const session = await getServerSession(authOptions); + if (!session?.user?.id) { + return NextResponse.json({ error: "unauthorized" }, { status: 401 }); + } + + const accessToken = (session.user as { githubAccessToken?: string }) + .githubAccessToken; + if (!accessToken) { + return NextResponse.json({ error: "no_github_link" }, { status: 412 }); + } + + const body = await request.json().catch(() => null); + const remoteUrls = Array.isArray(body?.remoteUrls) + ? body.remoteUrls.filter((v: unknown): v is string => typeof v === "string") + : []; + + try { + const archived = await checkArchivedStatus(remoteUrls, accessToken); + return NextResponse.json({ archived }); + } catch (error) { + logError(error, "POST /api/repos/check-archived"); + return NextResponse.json({ error: "github_api_error" }, { status: 502 }); + } +} From 02a4950cfa94239f6c5b5afc7921b8ad90a44f9a Mon Sep 17 00:00:00 2001 From: Rodrigo Matos Date: Wed, 26 Aug 2026 17:02:36 -0300 Subject: [PATCH 2/2] fix(platform): move normalizeRepoSlug out of the Datadog sync module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Build failed: repo-list.tsx ("use client") imported normalizeRepoSlug from lib/integrations/datadog/sync.ts, which transitively imports lib/supabase.ts (server-only, uses next/headers) via decryptCredentials. Importing anything from that file — even one dependency-free function — drags its whole module graph into the client bundle, and Turbopack correctly refuses to ship next/headers to the browser. Moved normalizeRepoSlug to a new lib/repo-slug.ts with zero dependencies. sync.ts now imports it from there and re-exports it (so the existing datadog-sync.test.ts import path keeps working unchanged); repo-list.tsx and github.ts import the new module directly instead of going through sync.ts. Verified with a local `npm run build` using the same placeholder env vars CI uses — reproduced the original failure, confirmed this fixes it. Co-Authored-By: claude-code_2-1-238_agent --- platform/lib/github.ts | 2 +- platform/lib/integrations/datadog/sync.ts | 25 +++---------------- platform/lib/repo-slug.ts | 24 ++++++++++++++++++ .../src/app/[tenant]/dashboard/repo-list.tsx | 2 +- 4 files changed, 29 insertions(+), 24 deletions(-) create mode 100644 platform/lib/repo-slug.ts diff --git a/platform/lib/github.ts b/platform/lib/github.ts index 8194a66..4b444d0 100644 --- a/platform/lib/github.ts +++ b/platform/lib/github.ts @@ -3,7 +3,7 @@ * Tokens come from the user's NextAuth session. */ -import { normalizeRepoSlug } from "./integrations/datadog/sync"; +import { normalizeRepoSlug } from "./repo-slug"; const API = "https://api.github.com"; diff --git a/platform/lib/integrations/datadog/sync.ts b/platform/lib/integrations/datadog/sync.ts index 2d33108..4125db4 100644 --- a/platform/lib/integrations/datadog/sync.ts +++ b/platform/lib/integrations/datadog/sync.ts @@ -25,6 +25,9 @@ import { import { logger } from "@/lib/debug"; import { decryptCredentials } from "@/lib/encryption"; +import { normalizeRepoSlug } from "@/lib/repo-slug"; + +export { normalizeRepoSlug }; const DEFAULT_BACKFILL_DAYS = 30; const PAGE_LIMIT = 100; @@ -243,28 +246,6 @@ async function loadRepoLookup( return { byNormalizedSlug }; } -/** - * Normalize a git remote URL or Datadog slug into "host/path" form - * (lowercased, no scheme, no `.git`, no trailing slash). Returns `null` - * for empty input. - */ -export function normalizeRepoSlug( - input: string | null | undefined, -): string | null { - if (!input) return null; - let s = input.trim().toLowerCase(); - if (!s) return null; - // git@github.com:org/repo.git → github.com/org/repo - s = s.replace(/^git@([^:]+):/, "$1/"); - // ssh://git@host/org/repo or https://host/org/repo → host/org/repo - s = s.replace(/^[a-z]+:\/\//, ""); - s = s.replace(/^git@/, ""); - s = s.replace(/^www\./, ""); - s = s.replace(/\.git$/, ""); - s = s.replace(/\/+$/, ""); - return s || null; -} - /** * Return the first occurrence of each item keyed by `idFn`. * diff --git a/platform/lib/repo-slug.ts b/platform/lib/repo-slug.ts new file mode 100644 index 0000000..c1f156a --- /dev/null +++ b/platform/lib/repo-slug.ts @@ -0,0 +1,24 @@ +/** + * Pure repo-slug normalization — no dependencies, safe to import from + * client components. Kept out of lib/integrations/datadog/sync.ts (which + * originally defined it) because that module transitively imports + * lib/supabase.ts (server-only, uses next/headers); a client component + * importing anything from that file at all pulls the whole server-only + * chain into the browser bundle and fails the build. + */ +export function normalizeRepoSlug( + input: string | null | undefined, +): string | null { + if (!input) return null; + let s = input.trim().toLowerCase(); + if (!s) return null; + // git@github.com:org/repo.git → github.com/org/repo + s = s.replace(/^git@([^:]+):/, "$1/"); + // ssh://git@host/org/repo or https://host/org/repo → host/org/repo + s = s.replace(/^[a-z]+:\/\//, ""); + s = s.replace(/^git@/, ""); + s = s.replace(/^www\./, ""); + s = s.replace(/\.git$/, ""); + s = s.replace(/\/+$/, ""); + return s || null; +} diff --git a/platform/src/app/[tenant]/dashboard/repo-list.tsx b/platform/src/app/[tenant]/dashboard/repo-list.tsx index 13399ea..0e0b6f6 100644 --- a/platform/src/app/[tenant]/dashboard/repo-list.tsx +++ b/platform/src/app/[tenant]/dashboard/repo-list.tsx @@ -16,7 +16,7 @@ import { useSession } from "next-auth/react"; import { DeleteRepositoryDialog } from "@/components/repos/DeleteRepositoryDialog"; import { Button } from "@/components/ui/button"; import { useTranslation } from "@/hooks/useTranslation"; -import { normalizeRepoSlug } from "@/lib/integrations/datadog/sync"; +import { normalizeRepoSlug } from "@/lib/repo-slug"; import { cn } from "@/lib/utils"; import type { RepoSummary } from "@/types/temporal"; import { healthIndicator } from "@/types/temporal";