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
87 changes: 69 additions & 18 deletions platform/lib/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@
* Tokens come from the user's NextAuth session.
*/

const API = 'https://api.github.com';
import { normalizeRepoSlug } from "./repo-slug";

const API = "https://api.github.com";

export interface GitHubOrgSummary {
id: number;
Expand All @@ -28,15 +30,17 @@ async function call<T>(path: string, accessToken: string): Promise<T> {
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;
}
Expand All @@ -61,7 +65,7 @@ interface RawUserDetail {
function parseLinkHeader(header: string | null): Record<string, string> {
if (!header) return {};
const links: Record<string, string> = {};
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];
}
Expand All @@ -72,15 +76,17 @@ async function callRaw(url: string, accessToken: string): Promise<Response> {
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;
}
Expand All @@ -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;
}

Expand Down Expand Up @@ -143,16 +149,21 @@ 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<GitHubOrgSummary[]> {
const raws = await call<RawOrg[]>('/user/orgs?per_page=100', accessToken);
export async function listUserOrgs(
accessToken: string,
): Promise<GitHubOrgSummary[]> {
const raws = await call<RawOrg[]>("/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.
const detailed = await Promise.all(
raws.map(async (raw) => {
let name: string | null = null;
try {
const detail = await call<RawOrgDetail>(`/orgs/${raw.login}`, accessToken);
const detail = await call<RawOrgDetail>(
`/orgs/${raw.login}`,
accessToken,
);
name = detail.name;
} catch {
// Non-fatal: keep going with the login as the displayable name.
Expand All @@ -169,3 +180,43 @@ export async function listUserOrgs(accessToken: string): Promise<GitHubOrgSummar

return detailed;
}

interface RawRepoDetail {
archived: boolean;
}

/**
* Checks GitHub's `archived` flag for each given remote URL, using the
* caller's own OAuth token. Ephemeral: nothing here is persisted, and
* nothing calls this automatically — it's meant to run on-demand for
* whatever repos are currently on screen, not as a background sync.
*
* The login scope has no `repo` grant, so a private repo 404s; that comes
* back as `null` (unknown) for that entry rather than failing the batch —
* archived status for private repos just can't be determined this way.
*/
export async function checkArchivedStatus(
remoteUrls: (string | null | undefined)[],
accessToken: string,
): Promise<Record<string, boolean | null>> {
const pathBySlug = new Map<string, string>();
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<RawRepoDetail>(`/repos/${path}`, accessToken);
return [slug, detail.archived] as const;
} catch {
return [slug, null] as const;
}
}),
);

return Object.fromEntries(entries);
}
25 changes: 3 additions & 22 deletions platform/lib/integrations/datadog/sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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`.
*
Expand Down
24 changes: 24 additions & 0 deletions platform/lib/repo-slug.ts
Original file line number Diff line number Diff line change
@@ -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;
}
13 changes: 13 additions & 0 deletions platform/lib/translations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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: {
Expand Down
Loading
Loading