chore: convert submodules to standard directories for true monorepo structure
This commit is contained in:
189
vibn-frontend/lib/integrations/github.ts
Normal file
189
vibn-frontend/lib/integrations/github.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* GitHub OAuth integration helpers.
|
||||
*
|
||||
* Storage layout — fs_users.data.integrations.github:
|
||||
* {
|
||||
* login: "octocat",
|
||||
* accessToken: <encryptSecret(token)>, // encrypted at rest
|
||||
* scope: "repo,read:user",
|
||||
* connectedAt: ISO,
|
||||
* }
|
||||
*
|
||||
* Tokens never leave the server. All API calls happen server-side; the
|
||||
* UI only ever sees the GitHub `login` and the list of repos.
|
||||
*
|
||||
* Scopes requested: `repo` (private+public read+write so we can mirror)
|
||||
* `read:user` (so we can show the connected username)
|
||||
*/
|
||||
|
||||
import { encryptSecret, decryptSecret } from "@/lib/auth/secret-box";
|
||||
import { query } from "@/lib/db-postgres";
|
||||
|
||||
const CLIENT_ID = process.env.GITHUB_CLIENT_ID ?? "";
|
||||
const CLIENT_SECRET = process.env.GITHUB_CLIENT_SECRET ?? "";
|
||||
const SCOPES = "repo,read:user";
|
||||
|
||||
export function isGithubOauthConfigured(): boolean {
|
||||
return CLIENT_ID.length > 0 && CLIENT_SECRET.length > 0;
|
||||
}
|
||||
|
||||
/** Build the GitHub authorize URL. `state` MUST be unguessable per request. */
|
||||
export function buildAuthorizeUrl(state: string, callbackUrl: string): string {
|
||||
const u = new URL("https://github.com/login/oauth/authorize");
|
||||
u.searchParams.set("client_id", CLIENT_ID);
|
||||
u.searchParams.set("redirect_uri", callbackUrl);
|
||||
u.searchParams.set("scope", SCOPES);
|
||||
u.searchParams.set("state", state);
|
||||
u.searchParams.set("allow_signup", "true");
|
||||
return u.toString();
|
||||
}
|
||||
|
||||
interface TokenExchangeResult {
|
||||
accessToken: string;
|
||||
scope: string;
|
||||
tokenType: string;
|
||||
}
|
||||
|
||||
/** POST /login/oauth/access_token — exchange auth code for an access token. */
|
||||
export async function exchangeCodeForToken(
|
||||
code: string, callbackUrl: string,
|
||||
): Promise<TokenExchangeResult> {
|
||||
const res = await fetch("https://github.com/login/oauth/access_token", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
client_id: CLIENT_ID,
|
||||
client_secret: CLIENT_SECRET,
|
||||
code,
|
||||
redirect_uri: callbackUrl,
|
||||
}),
|
||||
});
|
||||
const body = await res.json() as {
|
||||
access_token?: string; scope?: string; token_type?: string;
|
||||
error?: string; error_description?: string;
|
||||
};
|
||||
if (!res.ok || !body.access_token) {
|
||||
throw new Error(body.error_description || body.error || "GitHub token exchange failed");
|
||||
}
|
||||
return {
|
||||
accessToken: body.access_token,
|
||||
scope: body.scope ?? "",
|
||||
tokenType: body.token_type ?? "bearer",
|
||||
};
|
||||
}
|
||||
|
||||
/** GET /user — fetch the authenticated user's login + name. */
|
||||
export async function getAuthenticatedUser(token: string) {
|
||||
const res = await fetch("https://api.github.com/user", {
|
||||
headers: {
|
||||
"Authorization": `Bearer ${token}`,
|
||||
"Accept": "application/vnd.github+json",
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
},
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`GitHub /user failed: ${res.status} ${res.statusText}`);
|
||||
}
|
||||
return res.json() as Promise<{ login: string; name: string | null; avatar_url: string }>;
|
||||
}
|
||||
|
||||
interface GithubRepo {
|
||||
id: number;
|
||||
name: string;
|
||||
full_name: string;
|
||||
private: boolean;
|
||||
description: string | null;
|
||||
default_branch: string;
|
||||
html_url: string;
|
||||
pushed_at: string | null;
|
||||
language: string | null;
|
||||
fork: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /user/repos — list repos the authenticated user can access.
|
||||
* Returns at most `perPage` repos; we cap at 100 (GitHub max) and don't
|
||||
* paginate further. The picker UI does client-side filter on top.
|
||||
*/
|
||||
export async function listUserRepos(token: string, perPage = 100): Promise<GithubRepo[]> {
|
||||
const u = new URL("https://api.github.com/user/repos");
|
||||
u.searchParams.set("per_page", String(perPage));
|
||||
u.searchParams.set("sort", "pushed");
|
||||
u.searchParams.set("affiliation", "owner,collaborator,organization_member");
|
||||
const res = await fetch(u.toString(), {
|
||||
headers: {
|
||||
"Authorization": `Bearer ${token}`,
|
||||
"Accept": "application/vnd.github+json",
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
},
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`GitHub /user/repos failed: ${res.status} ${res.statusText}`);
|
||||
}
|
||||
return res.json() as Promise<GithubRepo[]>;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// fs_users persistence
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface StoredGithubIntegration {
|
||||
login: string;
|
||||
accessToken: string; // encrypted
|
||||
scope: string;
|
||||
connectedAt: string;
|
||||
}
|
||||
|
||||
/** Write the integration to fs_users.data.integrations.github. */
|
||||
export async function persistGithubIntegration(
|
||||
email: string, login: string, plainToken: string, scope: string,
|
||||
): Promise<void> {
|
||||
const blob: StoredGithubIntegration = {
|
||||
login,
|
||||
accessToken: encryptSecret(plainToken),
|
||||
scope,
|
||||
connectedAt: new Date().toISOString(),
|
||||
};
|
||||
await query(
|
||||
`UPDATE fs_users
|
||||
SET data = jsonb_set(
|
||||
jsonb_set(data, '{integrations}', COALESCE(data->'integrations','{}'::jsonb), true),
|
||||
'{integrations,github}', $2::jsonb, true
|
||||
),
|
||||
updated_at = NOW()
|
||||
WHERE data->>'email' = $1`,
|
||||
[email, JSON.stringify(blob)],
|
||||
);
|
||||
}
|
||||
|
||||
/** Read + decrypt the GitHub access token for a user, if any. */
|
||||
export async function loadGithubIntegration(
|
||||
email: string,
|
||||
): Promise<{ login: string; token: string; scope: string } | null> {
|
||||
const rows = await query<{ blob: StoredGithubIntegration | null }>(
|
||||
`SELECT data->'integrations'->'github' AS blob
|
||||
FROM fs_users WHERE data->>'email' = $1 LIMIT 1`,
|
||||
[email],
|
||||
);
|
||||
const blob = rows[0]?.blob;
|
||||
if (!blob || !blob.accessToken) return null;
|
||||
try {
|
||||
return { login: blob.login, token: decryptSecret(blob.accessToken), scope: blob.scope };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Drop the stored integration. */
|
||||
export async function disconnectGithubIntegration(email: string): Promise<void> {
|
||||
await query(
|
||||
`UPDATE fs_users
|
||||
SET data = data #- '{integrations,github}',
|
||||
updated_at = NOW()
|
||||
WHERE data->>'email' = $1`,
|
||||
[email],
|
||||
);
|
||||
}
|
||||
519
vibn-frontend/lib/integrations/sentry.ts
Normal file
519
vibn-frontend/lib/integrations/sentry.ts
Normal file
@@ -0,0 +1,519 @@
|
||||
/**
|
||||
* Sentry-as-product integration.
|
||||
*
|
||||
* Provisions a Sentry project per Vibn project under the shared
|
||||
* `vibnai` Sentry org, then makes its DSN + auth token available
|
||||
* to any Coolify app deployed for that project. Real-user errors
|
||||
* from the user's deployed app land in Sentry; AI tools (Stage 3)
|
||||
* read the issue feed back into chat (Stage 4).
|
||||
*
|
||||
* Design choices:
|
||||
* - **Idempotent.** Looks up existing Sentry projects by slug
|
||||
* before creating, so reruns are safe.
|
||||
* - **Shared org token.** The same `SENTRY_AUTH_TOKEN` we use to
|
||||
* upload source maps for vibn-frontend is reused across every
|
||||
* user project — it has org-write scope. This means we DON'T
|
||||
* need per-project auth tokens, just per-project DSNs.
|
||||
* - **Slug convention.** `vibn-{workspace}-{projectSlug}`. Avoids
|
||||
* collisions across workspaces and stays under Sentry's
|
||||
* 50-char project slug limit. Clamped if longer.
|
||||
* - **Soft failure.** If Sentry provisioning fails (rate limit,
|
||||
* network, token revoked) we log and continue. The Vibn
|
||||
* project still works without Sentry; we'll lazily retry on
|
||||
* the next deploy.
|
||||
*
|
||||
* See SENTRY_AS_PRODUCT.md for the full proposal context.
|
||||
*/
|
||||
|
||||
import { query } from '@/lib/db-postgres';
|
||||
import { upsertApplicationEnv } from '@/lib/coolify';
|
||||
|
||||
const SENTRY_API_BASE = 'https://de.sentry.io/api/0';
|
||||
const SENTRY_ORG_SLUG = 'vibnai';
|
||||
const SENTRY_TEAM_SLUG = 'vibnai'; // Default team name matches org slug for personal/single-team setups.
|
||||
|
||||
export interface SentryProjectInfo {
|
||||
/** Sentry project slug, e.g. `vibn-mark-account-checkout-app`. */
|
||||
slug: string;
|
||||
/** Public DSN for runtime error capture. NEXT_PUBLIC_SENTRY_DSN. */
|
||||
dsn: string;
|
||||
/** When this row was created/updated. */
|
||||
provisionedAt: string;
|
||||
}
|
||||
|
||||
export interface ProvisionInput {
|
||||
projectId: string;
|
||||
/** Vibn workspace slug, e.g. "mark-account". */
|
||||
workspaceSlug: string;
|
||||
/** Vibn project slug, e.g. "checkout-app". */
|
||||
projectSlug: string;
|
||||
/** Display name shown in the Sentry UI. */
|
||||
projectName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the Sentry project for a given Vibn project, creating
|
||||
* it if missing. Persists the result to fs_projects.data.sentry
|
||||
* so subsequent calls hit the cache instead of Sentry's API.
|
||||
*
|
||||
* Returns null if Sentry provisioning fails — caller should log
|
||||
* and continue.
|
||||
*/
|
||||
export async function ensureSentryProject(
|
||||
input: ProvisionInput,
|
||||
): Promise<SentryProjectInfo | null> {
|
||||
const authToken = process.env.SENTRY_AUTH_TOKEN;
|
||||
if (!authToken) {
|
||||
console.warn('[sentry] SENTRY_AUTH_TOKEN missing — skipping provisioning');
|
||||
return null;
|
||||
}
|
||||
|
||||
// 1. Fast path: already provisioned for this Vibn project.
|
||||
const cached = await loadSentryFromProject(input.projectId);
|
||||
if (cached) return cached;
|
||||
|
||||
// 2. Build the deterministic slug. Sentry caps at 50 chars and
|
||||
// only allows lowercase a–z, 0–9, dashes.
|
||||
const slug = buildSentrySlug(input.workspaceSlug, input.projectSlug);
|
||||
|
||||
try {
|
||||
// 3. Try to look up existing Sentry project (handles the case
|
||||
// where fs_projects was wiped but Sentry still has the project).
|
||||
const existing = await fetchSentryProject(slug, authToken);
|
||||
if (existing) {
|
||||
const info = await materialize(input.projectId, existing, authToken);
|
||||
if (info) return info;
|
||||
}
|
||||
|
||||
// 4. Create fresh.
|
||||
const created = await createSentryProject({
|
||||
slug,
|
||||
name: input.projectName.slice(0, 50),
|
||||
authToken,
|
||||
});
|
||||
if (!created) return null;
|
||||
|
||||
return await materialize(input.projectId, created, authToken);
|
||||
} catch (err) {
|
||||
console.error('[sentry] ensureSentryProject failed', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the standard Sentry env vars on a Coolify application
|
||||
* so that its next build inlines the DSN and uploads source maps.
|
||||
* Idempotent — safe to call on every apps.create. Soft-fails on
|
||||
* any per-key error so a single failed upsert doesn't block deploy.
|
||||
*
|
||||
* Env vars set:
|
||||
* - NEXT_PUBLIC_SENTRY_DSN — public DSN for runtime capture
|
||||
* - SENTRY_AUTH_TOKEN — shared org token for source map upload
|
||||
*
|
||||
* Both are marked Coolify-default (is_buildtime=true is_runtime=true);
|
||||
* the public DSN must be present at build time so Next.js inlines
|
||||
* it into the client bundle.
|
||||
*/
|
||||
export async function applySentryEnvToCoolifyApp(
|
||||
coolifyAppUuid: string,
|
||||
projectId: string,
|
||||
): Promise<void> {
|
||||
const sentry = await loadSentryFromProject(projectId);
|
||||
if (!sentry) return; // Project not yet provisioned in Sentry — Stage 1 will retry.
|
||||
|
||||
const authToken = process.env.SENTRY_AUTH_TOKEN;
|
||||
if (!authToken) return;
|
||||
|
||||
const envs: Array<[string, string]> = [
|
||||
['NEXT_PUBLIC_SENTRY_DSN', sentry.dsn],
|
||||
['SENTRY_AUTH_TOKEN', authToken],
|
||||
];
|
||||
|
||||
for (const [key, value] of envs) {
|
||||
try {
|
||||
await upsertApplicationEnv(coolifyAppUuid, { key, value });
|
||||
} catch (err) {
|
||||
console.warn(`[sentry] upsert ${key} on ${coolifyAppUuid} failed`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the Sentry project info for a Vibn project if already
|
||||
* provisioned, else null. Read-only — does NOT call Sentry's API.
|
||||
*/
|
||||
export async function loadSentryFromProject(
|
||||
projectId: string,
|
||||
): Promise<SentryProjectInfo | null> {
|
||||
const rows = await query<{ data: any }>(
|
||||
`SELECT data FROM fs_projects WHERE id = $1 LIMIT 1`,
|
||||
[projectId],
|
||||
);
|
||||
const sentry = rows[0]?.data?.sentry;
|
||||
if (sentry?.slug && sentry?.dsn && sentry?.provisionedAt) {
|
||||
return sentry as SentryProjectInfo;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
// Issue feed — Stage 3 MCP tools read these
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface SentryIssueSummary {
|
||||
id: string;
|
||||
title: string;
|
||||
/** "error" | "fatal" | "warning" | "info" | "debug" */
|
||||
level: string;
|
||||
/** Total event count (lifetime). */
|
||||
count: number;
|
||||
/** ISO of most recent occurrence. */
|
||||
lastSeen: string;
|
||||
/** ISO of first occurrence. */
|
||||
firstSeen: string;
|
||||
/** "unresolved" | "resolved" | "ignored" — we filter to unresolved. */
|
||||
status: string;
|
||||
/** Best-effort culprit string from Sentry, e.g. "GET /api/checkout". */
|
||||
culprit: string | null;
|
||||
/** Direct URL to the issue in Sentry's UI. */
|
||||
permalink: string | null;
|
||||
}
|
||||
|
||||
export interface SentryEventDetail {
|
||||
/** Sentry event ID (the canonical "fingerprint" of this occurrence). */
|
||||
eventId: string;
|
||||
/** Most recent event's render of the stack trace (top frames first). */
|
||||
stackFrames: Array<{
|
||||
function: string | null;
|
||||
filename: string | null;
|
||||
lineno: number | null;
|
||||
colno: number | null;
|
||||
/** Source code surrounding the line if Sentry has source maps. */
|
||||
contextLine: string | null;
|
||||
}>;
|
||||
/** User-context tag (email/id/username/ip), if Sentry captured one. */
|
||||
user: { email?: string; id?: string; username?: string; ipAddress?: string } | null;
|
||||
/** Request method + URL if this was an HTTP-facing error. */
|
||||
request: { method?: string; url?: string } | null;
|
||||
/** Truncated breadcrumbs (last 20) — clicks, fetches, navigations. */
|
||||
breadcrumbs: Array<{
|
||||
type: string | null;
|
||||
category: string | null;
|
||||
message: string | null;
|
||||
timestamp: string | null;
|
||||
}>;
|
||||
/** Direct URL to a Session Replay if the user had one recorded. */
|
||||
replayUrl: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* List recent unresolved issues for a Vibn project's Sentry project.
|
||||
* Returns [] if Sentry isn't provisioned yet — caller should treat
|
||||
* that as "no errors", which is functionally correct (no Sentry =
|
||||
* no error capture path).
|
||||
*/
|
||||
export async function listRecentSentryIssues(
|
||||
projectId: string,
|
||||
options?: { limit?: number; sinceHours?: number },
|
||||
): Promise<SentryIssueSummary[]> {
|
||||
const sentry = await loadSentryFromProject(projectId);
|
||||
if (!sentry) return [];
|
||||
|
||||
const authToken = process.env.SENTRY_AUTH_TOKEN;
|
||||
if (!authToken) return [];
|
||||
|
||||
const limit = clampLimit(options?.limit ?? 10);
|
||||
const sinceHours = options?.sinceHours ?? 24;
|
||||
const statsPeriod = `${sinceHours}h`;
|
||||
|
||||
const url = new URL(
|
||||
`${SENTRY_API_BASE}/projects/${SENTRY_ORG_SLUG}/${sentry.slug}/issues/`,
|
||||
);
|
||||
url.searchParams.set('limit', String(limit));
|
||||
url.searchParams.set('query', 'is:unresolved');
|
||||
url.searchParams.set('statsPeriod', statsPeriod);
|
||||
url.searchParams.set('sort', 'date');
|
||||
|
||||
const res = await fetch(url, {
|
||||
headers: { Authorization: `Bearer ${authToken}` },
|
||||
});
|
||||
if (!res.ok) {
|
||||
console.warn(
|
||||
`[sentry] listRecentSentryIssues ${res.status}: ${await res.text()}`,
|
||||
);
|
||||
return [];
|
||||
}
|
||||
const raw = (await res.json()) as Array<Record<string, any>>;
|
||||
return raw.map((i) => ({
|
||||
id: String(i.id),
|
||||
title: String(i.title ?? '(untitled)'),
|
||||
level: String(i.level ?? 'error'),
|
||||
count: Number(i.count ?? 0),
|
||||
lastSeen: String(i.lastSeen ?? ''),
|
||||
firstSeen: String(i.firstSeen ?? ''),
|
||||
status: String(i.status ?? 'unresolved'),
|
||||
culprit: i.culprit ? String(i.culprit) : null,
|
||||
permalink: i.permalink ? String(i.permalink) : null,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the latest event for an issue, with stack trace,
|
||||
* breadcrumbs, user, request, and Session Replay link if any.
|
||||
* Returns null if the issue isn't found or Sentry isn't ready.
|
||||
*/
|
||||
export async function getSentryIssueDetail(
|
||||
projectId: string,
|
||||
issueId: string,
|
||||
): Promise<SentryEventDetail | null> {
|
||||
const sentry = await loadSentryFromProject(projectId);
|
||||
if (!sentry) return null;
|
||||
const authToken = process.env.SENTRY_AUTH_TOKEN;
|
||||
if (!authToken) return null;
|
||||
|
||||
const res = await fetch(
|
||||
`${SENTRY_API_BASE}/issues/${encodeURIComponent(issueId)}/events/latest/`,
|
||||
{
|
||||
headers: { Authorization: `Bearer ${authToken}` },
|
||||
},
|
||||
);
|
||||
if (!res.ok) {
|
||||
console.warn(
|
||||
`[sentry] getSentryIssueDetail ${res.status}: ${await res.text()}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
const e = (await res.json()) as Record<string, any>;
|
||||
|
||||
// Sentry stores frames in a few different places depending on
|
||||
// platform. The exception entry has the most reliable shape.
|
||||
const exceptionEntry = (e.entries || []).find(
|
||||
(entry: any) => entry?.type === 'exception',
|
||||
);
|
||||
const firstException = exceptionEntry?.data?.values?.[0];
|
||||
const frames =
|
||||
(firstException?.stacktrace?.frames as Array<Record<string, any>> | undefined) ?? [];
|
||||
// Sentry returns frames in oldest-first order; reverse so top of
|
||||
// stack (the line that actually threw) is first — which is what
|
||||
// every developer scans first.
|
||||
const stackFrames = [...frames].reverse().slice(0, 12).map((f) => ({
|
||||
function: f.function ?? null,
|
||||
filename: f.filename ?? null,
|
||||
lineno: typeof f.lineno === 'number' ? f.lineno : null,
|
||||
colno: typeof f.colno === 'number' ? f.colno : null,
|
||||
contextLine: f.contextLine ?? null,
|
||||
}));
|
||||
|
||||
const breadcrumbsEntry = (e.entries || []).find(
|
||||
(entry: any) => entry?.type === 'breadcrumbs',
|
||||
);
|
||||
const breadcrumbs =
|
||||
((breadcrumbsEntry?.data?.values as Array<Record<string, any>> | undefined) ?? [])
|
||||
.slice(-20)
|
||||
.map((b) => ({
|
||||
type: b.type ?? null,
|
||||
category: b.category ?? null,
|
||||
message: b.message ?? null,
|
||||
timestamp: b.timestamp ?? null,
|
||||
}));
|
||||
|
||||
const requestEntry = (e.entries || []).find(
|
||||
(entry: any) => entry?.type === 'request',
|
||||
);
|
||||
const request = requestEntry?.data
|
||||
? {
|
||||
method: requestEntry.data.method ?? undefined,
|
||||
url: requestEntry.data.url ?? undefined,
|
||||
}
|
||||
: null;
|
||||
|
||||
// Session Replay: Sentry attaches a `replayId` tag on events that
|
||||
// have one. Build the canonical UI URL.
|
||||
const replayId = (e.tags as Array<Record<string, any>> | undefined)?.find(
|
||||
(t) => t.key === 'replayId',
|
||||
)?.value;
|
||||
const replayUrl = replayId
|
||||
? `https://${SENTRY_ORG_SLUG}.sentry.io/replays/${replayId}/`
|
||||
: null;
|
||||
|
||||
return {
|
||||
eventId: String(e.eventID ?? e.id ?? ''),
|
||||
stackFrames,
|
||||
user: e.user ? sanitizeUser(e.user) : null,
|
||||
request,
|
||||
breadcrumbs,
|
||||
replayUrl,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark an issue resolved. Used by the AI after it ships a fix and
|
||||
* verifies the bug no longer fires (e.g. via tests, log watch, or
|
||||
* explicit user confirmation).
|
||||
*/
|
||||
export async function resolveSentryIssue(
|
||||
projectId: string,
|
||||
issueId: string,
|
||||
): Promise<boolean> {
|
||||
const sentry = await loadSentryFromProject(projectId);
|
||||
if (!sentry) return false;
|
||||
const authToken = process.env.SENTRY_AUTH_TOKEN;
|
||||
if (!authToken) return false;
|
||||
|
||||
const res = await fetch(
|
||||
`${SENTRY_API_BASE}/issues/${encodeURIComponent(issueId)}/`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ status: 'resolved' }),
|
||||
},
|
||||
);
|
||||
return res.ok;
|
||||
}
|
||||
|
||||
function clampLimit(n: number): number {
|
||||
if (!Number.isFinite(n)) return 10;
|
||||
return Math.max(1, Math.min(50, Math.floor(n)));
|
||||
}
|
||||
|
||||
function sanitizeUser(u: Record<string, any>): SentryEventDetail['user'] {
|
||||
return {
|
||||
email: u.email ?? undefined,
|
||||
id: u.id ?? undefined,
|
||||
username: u.username ?? undefined,
|
||||
ipAddress: u.ip_address ?? u.ipAddress ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
// Internal helpers
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function buildSentrySlug(workspaceSlug: string, projectSlug: string): string {
|
||||
const raw = `vibn-${workspaceSlug}-${projectSlug}`
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9-]+/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, '');
|
||||
// Sentry: 50 char max. Truncate from the end (preserves the
|
||||
// `vibn-{workspace}` prefix which keeps slugs distinguishable
|
||||
// across workspaces).
|
||||
return raw.slice(0, 50);
|
||||
}
|
||||
|
||||
interface SentryApiProject {
|
||||
slug: string;
|
||||
name: string;
|
||||
id: string;
|
||||
}
|
||||
|
||||
async function fetchSentryProject(
|
||||
slug: string,
|
||||
authToken: string,
|
||||
): Promise<SentryApiProject | null> {
|
||||
const res = await fetch(
|
||||
`${SENTRY_API_BASE}/projects/${SENTRY_ORG_SLUG}/${slug}/`,
|
||||
{
|
||||
headers: { Authorization: `Bearer ${authToken}` },
|
||||
},
|
||||
);
|
||||
if (res.status === 404) return null;
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
`Sentry GET project failed: ${res.status} ${await res.text()}`,
|
||||
);
|
||||
}
|
||||
return (await res.json()) as SentryApiProject;
|
||||
}
|
||||
|
||||
async function createSentryProject(input: {
|
||||
slug: string;
|
||||
name: string;
|
||||
authToken: string;
|
||||
}): Promise<SentryApiProject | null> {
|
||||
const res = await fetch(
|
||||
`${SENTRY_API_BASE}/teams/${SENTRY_ORG_SLUG}/${SENTRY_TEAM_SLUG}/projects/`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${input.authToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: input.name,
|
||||
slug: input.slug,
|
||||
platform: 'javascript-nextjs', // Most common case; doesn't gate which SDK actually sends events.
|
||||
}),
|
||||
},
|
||||
);
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
// 409 = slug already exists in another team; we tried the GET
|
||||
// path first, so this is genuinely a race or a slug conflict.
|
||||
if (res.status === 409) {
|
||||
console.warn(`[sentry] slug ${input.slug} taken — retry GET`);
|
||||
return await fetchSentryProject(input.slug, input.authToken);
|
||||
}
|
||||
throw new Error(`Sentry POST project failed: ${res.status} ${text}`);
|
||||
}
|
||||
return (await res.json()) as SentryApiProject;
|
||||
}
|
||||
|
||||
interface SentryClientKey {
|
||||
dsn: { public: string };
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
async function fetchProjectDsn(
|
||||
slug: string,
|
||||
authToken: string,
|
||||
): Promise<string | null> {
|
||||
const res = await fetch(
|
||||
`${SENTRY_API_BASE}/projects/${SENTRY_ORG_SLUG}/${slug}/keys/`,
|
||||
{
|
||||
headers: { Authorization: `Bearer ${authToken}` },
|
||||
},
|
||||
);
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
`Sentry GET keys failed: ${res.status} ${await res.text()}`,
|
||||
);
|
||||
}
|
||||
const keys = (await res.json()) as SentryClientKey[];
|
||||
// Sentry auto-creates a "Default" key on project creation; pick
|
||||
// the first active one. Multiple keys exist in pro setups but
|
||||
// we always want the live one.
|
||||
const active = keys.find((k) => k.isActive) ?? keys[0];
|
||||
return active?.dsn?.public ?? null;
|
||||
}
|
||||
|
||||
async function materialize(
|
||||
projectId: string,
|
||||
apiProj: SentryApiProject,
|
||||
authToken: string,
|
||||
): Promise<SentryProjectInfo | null> {
|
||||
const dsn = await fetchProjectDsn(apiProj.slug, authToken);
|
||||
if (!dsn) {
|
||||
console.error(`[sentry] no DSN returned for ${apiProj.slug}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const info: SentryProjectInfo = {
|
||||
slug: apiProj.slug,
|
||||
dsn,
|
||||
provisionedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
await query(
|
||||
`UPDATE fs_projects
|
||||
SET data = jsonb_set(COALESCE(data, '{}'::jsonb), '{sentry}', $2::jsonb, true),
|
||||
updated_at = NOW()
|
||||
WHERE id = $1`,
|
||||
[projectId, JSON.stringify(info)],
|
||||
);
|
||||
|
||||
return info;
|
||||
}
|
||||
Reference in New Issue
Block a user