d25d482dc2
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
636 lines
21 KiB
TypeScript
636 lines
21 KiB
TypeScript
import { createLogger } from '@sim/logger'
|
|
import { getErrorMessage, toError } from '@sim/utils/errors'
|
|
import { secureFetchWithRetry } from '@/lib/knowledge/documents/secure-fetch.server'
|
|
import { VALIDATE_RETRY_OPTIONS } from '@/lib/knowledge/documents/utils'
|
|
import { DEFAULT_QUERY, sentryConnectorMeta } from '@/connectors/sentry/meta'
|
|
import type { ConnectorConfig, ExternalDocument, ExternalDocumentList } from '@/connectors/types'
|
|
import { parseTagDate } from '@/connectors/utils'
|
|
|
|
const logger = createLogger('SentryConnector')
|
|
|
|
const DEFAULT_HOST = 'sentry.io'
|
|
const ISSUES_PER_PAGE = 100
|
|
|
|
/**
|
|
* Allowed `statsPeriod` values for the project issues list endpoint. Sentry's
|
|
* project issues endpoint only honors `24h` (default) or `14d` for its timeline
|
|
* stats; an empty value disables the stats window. Other periods (e.g. `90d`)
|
|
* are accepted by the organization issues endpoint but not this one, so they are
|
|
* rejected during validation to avoid a silently-ignored filter.
|
|
*/
|
|
const ALLOWED_STATS_PERIODS = new Set(['24h', '14d'])
|
|
|
|
/**
|
|
* Metadata block on a Sentry issue, carrying the human-readable error type/value.
|
|
*/
|
|
interface SentryIssueMetadata {
|
|
type?: string
|
|
value?: string
|
|
function?: string
|
|
title?: string
|
|
}
|
|
|
|
/**
|
|
* A single issue (error group) returned by the issues list/detail endpoints.
|
|
*/
|
|
interface SentryIssue {
|
|
id: string
|
|
shortId?: string
|
|
title?: string
|
|
culprit?: string | null
|
|
permalink?: string
|
|
logger?: string | null
|
|
level?: string
|
|
status?: string
|
|
platform?: string | null
|
|
type?: string | null
|
|
metadata?: SentryIssueMetadata
|
|
/** Sentry returns the event count as a string (e.g. "12"), not a number. */
|
|
count?: string
|
|
userCount?: number
|
|
firstSeen?: string
|
|
lastSeen?: string
|
|
}
|
|
|
|
/**
|
|
* One entry inside a Sentry event. Entries carry the structured payload (exception,
|
|
* breadcrumbs, request, message) keyed by `type`, with the shape under `data` varying
|
|
* per entry type.
|
|
*/
|
|
interface SentryEventEntry {
|
|
type?: string
|
|
data?: unknown
|
|
}
|
|
|
|
/**
|
|
* A key/value tag pair attached to a Sentry event.
|
|
*/
|
|
interface SentryEventTag {
|
|
key?: string
|
|
value?: string
|
|
}
|
|
|
|
/**
|
|
* The latest event for an issue, used to enrich the synced document with the concrete
|
|
* message, exception detail, and tags from the most recent occurrence.
|
|
*/
|
|
interface SentryEvent {
|
|
id?: string
|
|
eventID?: string
|
|
message?: string
|
|
title?: string
|
|
culprit?: string | null
|
|
platform?: string | null
|
|
dateCreated?: string
|
|
metadata?: SentryIssueMetadata
|
|
entries?: SentryEventEntry[]
|
|
tags?: SentryEventTag[]
|
|
}
|
|
|
|
/**
|
|
* The shape of an exception entry's `data` payload: a list of exception values, each
|
|
* with a type, message, and an optional rendered stack frame list.
|
|
*/
|
|
interface SentryExceptionData {
|
|
values?: {
|
|
type?: string
|
|
value?: string
|
|
module?: string
|
|
stacktrace?: {
|
|
frames?: {
|
|
filename?: string
|
|
function?: string
|
|
lineNo?: number
|
|
module?: string
|
|
}[]
|
|
}
|
|
}[]
|
|
}
|
|
|
|
/**
|
|
* Resolved connector source configuration after normalization.
|
|
*/
|
|
interface SentrySourceConfig {
|
|
/** Bare host (no protocol, no trailing slash), e.g. `sentry.io` or a self-hosted host. */
|
|
host: string
|
|
/** REST API base, e.g. `https://sentry.io/api/0`. */
|
|
apiBase: string
|
|
organization: string
|
|
project: string
|
|
query: string
|
|
statsPeriod: string
|
|
environment: string
|
|
maxIssues: number
|
|
}
|
|
|
|
/**
|
|
* Normalizes the host config value: trims whitespace, strips any protocol prefix,
|
|
* trailing slashes, and a pasted `/api` or `/api/0` suffix (the connector appends
|
|
* `/api/0` itself), and falls back to sentry.io when empty. Genuine path prefixes
|
|
* (e.g. `company.com/sentry` for subpath self-hosted installs) are preserved.
|
|
*/
|
|
function normalizeHost(rawHost: unknown): string {
|
|
const host = typeof rawHost === 'string' ? rawHost.trim() : ''
|
|
if (!host) return DEFAULT_HOST
|
|
return host
|
|
.replace(/^https?:\/\//i, '')
|
|
.replace(/\/+$/, '')
|
|
.replace(/\/api(\/0)?$/i, '')
|
|
.replace(/\/+$/, '')
|
|
.trim()
|
|
}
|
|
|
|
/**
|
|
* Reads and normalizes the connector source configuration once per call.
|
|
*/
|
|
function readSourceConfig(sourceConfig: Record<string, unknown>): SentrySourceConfig {
|
|
const host = normalizeHost(sourceConfig.baseUrl)
|
|
const organization =
|
|
typeof sourceConfig.organization === 'string' ? sourceConfig.organization.trim() : ''
|
|
const project = typeof sourceConfig.project === 'string' ? sourceConfig.project.trim() : ''
|
|
const query =
|
|
typeof sourceConfig.query === 'string' && sourceConfig.query.trim()
|
|
? sourceConfig.query.trim()
|
|
: DEFAULT_QUERY
|
|
const statsPeriod =
|
|
typeof sourceConfig.statsPeriod === 'string' ? sourceConfig.statsPeriod.trim() : ''
|
|
const environment =
|
|
typeof sourceConfig.environment === 'string' ? sourceConfig.environment.trim() : ''
|
|
const maxIssues = sourceConfig.maxIssues ? Number(sourceConfig.maxIssues) : 0
|
|
|
|
return {
|
|
host,
|
|
apiBase: `https://${host}/api/0`,
|
|
organization,
|
|
project,
|
|
query,
|
|
statsPeriod,
|
|
environment,
|
|
maxIssues,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Builds the standard JSON request headers carrying the Sentry auth token.
|
|
*/
|
|
function authHeaders(accessToken: string): Record<string, string> {
|
|
return {
|
|
Authorization: `Bearer ${accessToken}`,
|
|
'Content-Type': 'application/json',
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Reads the `cursor` of the `rel="next"` link from a Sentry `Link` header.
|
|
*
|
|
* Sentry paginates via the `Link` header: each link is annotated with `rel`,
|
|
* `results`, and `cursor` attributes, e.g.
|
|
* `<https://…/issues/?cursor=0:100:0>; rel="next"; results="true"; cursor="0:100:0"`.
|
|
* A further page exists only when the `next` link reports `results="true"`; when it
|
|
* reports `results="false"` (or the header is absent) the cursor points at an empty
|
|
* page and pagination must stop. The cursor is read from the `cursor="…"` attribute,
|
|
* which is the canonical token Sentry expects echoed back on the next request.
|
|
*/
|
|
function parseNextCursor(linkHeader: string | null): string | undefined {
|
|
if (!linkHeader) return undefined
|
|
|
|
for (const part of linkHeader.split(',')) {
|
|
if (!/rel="next"/.test(part)) continue
|
|
if (!/results="true"/.test(part)) return undefined
|
|
const cursorMatch = part.match(/cursor="([^"]*)"/)
|
|
if (cursorMatch) return cursorMatch[1]
|
|
return undefined
|
|
}
|
|
|
|
return undefined
|
|
}
|
|
|
|
/**
|
|
* Builds the metadata-based content hash for an issue.
|
|
*
|
|
* The hash combines the issue id, its status, and `lastSeen`. `lastSeen` advances every
|
|
* time a new event lands on the group, which is exactly when the latest-event content can
|
|
* change — so it captures content freshness without hashing the downloaded body. `status`
|
|
* is included so resolve/ignore transitions also re-sync. `count` is deliberately omitted:
|
|
* it changes on every single occurrence and would churn the document on each event even
|
|
* when `lastSeen` already moved, providing no extra signal over `lastSeen`.
|
|
*
|
|
* The hash is derived purely from issue metadata present on both the list stub and the
|
|
* getDocument detail fetch, so both paths produce an identical hash for the same issue
|
|
* snapshot. If a fresh event lands between listing and hydration, `lastSeen` advances and
|
|
* getDocument computes a newer hash; the sync engine stores that newer hash, which the next
|
|
* list pass reproduces — so the document converges without churn.
|
|
*/
|
|
function buildContentHash(issue: SentryIssue): string {
|
|
return `sentry:${issue.id}:${issue.status ?? ''}:${issue.lastSeen ?? ''}`
|
|
}
|
|
|
|
/**
|
|
* Builds the document title, preferring the issue title and falling back to the
|
|
* metadata type/value or short id.
|
|
*/
|
|
function buildTitle(issue: SentryIssue): string {
|
|
const title = issue.title?.trim()
|
|
if (title) return title
|
|
|
|
const metaType = issue.metadata?.type?.trim()
|
|
const metaValue = issue.metadata?.value?.trim()
|
|
if (metaType && metaValue) return `${metaType}: ${metaValue}`
|
|
return metaType || issue.shortId || `Issue ${issue.id}`
|
|
}
|
|
|
|
/**
|
|
* Collects the source-specific metadata fed to mapTags. Shared between the list stub and
|
|
* getDocument so tag values stay consistent regardless of which path produced the doc.
|
|
*/
|
|
function buildMetadata(issue: SentryIssue): Record<string, unknown> {
|
|
return {
|
|
level: issue.level,
|
|
status: issue.status,
|
|
firstSeen: issue.firstSeen,
|
|
lastSeen: issue.lastSeen,
|
|
count: issue.count != null ? Number(issue.count) : undefined,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Creates a lightweight document stub from a list entry. No per-issue API calls — the
|
|
* latest-event content is deferred to getDocument and only fetched for new/changed issues.
|
|
*/
|
|
function issueToStub(issue: SentryIssue): ExternalDocument {
|
|
return {
|
|
externalId: issue.id,
|
|
title: buildTitle(issue),
|
|
content: '',
|
|
contentDeferred: true,
|
|
mimeType: 'text/plain',
|
|
sourceUrl: issue.permalink || undefined,
|
|
contentHash: buildContentHash(issue),
|
|
metadata: buildMetadata(issue),
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Renders the exception entry of a latest event into readable lines: each exception's
|
|
* type/value plus a compact, top-down stack frame list.
|
|
*/
|
|
function formatException(data: SentryExceptionData): string[] {
|
|
const lines: string[] = []
|
|
|
|
for (const value of data.values ?? []) {
|
|
const header = [value.type, value.value].filter(Boolean).join(': ')
|
|
if (header) lines.push(header)
|
|
|
|
const frames = value.stacktrace?.frames ?? []
|
|
for (const frame of frames.slice().reverse()) {
|
|
const location = [frame.module || frame.filename, frame.function].filter(Boolean).join(' in ')
|
|
const lineNo = frame.lineNo != null ? `:${frame.lineNo}` : ''
|
|
if (location) lines.push(` at ${location}${lineNo}`)
|
|
}
|
|
}
|
|
|
|
return lines
|
|
}
|
|
|
|
/**
|
|
* Formats an issue and its latest event into a single plain-text document covering the
|
|
* title, culprit, counts, the latest event's message/exception, and event tags.
|
|
*/
|
|
function formatIssueContent(issue: SentryIssue, event: SentryEvent | null): string {
|
|
const parts: string[] = []
|
|
|
|
parts.push(`Issue: ${buildTitle(issue)}`)
|
|
if (issue.shortId) parts.push(`Short ID: ${issue.shortId}`)
|
|
if (issue.culprit) parts.push(`Culprit: ${issue.culprit}`)
|
|
if (issue.level) parts.push(`Level: ${issue.level}`)
|
|
if (issue.status) parts.push(`Status: ${issue.status}`)
|
|
if (issue.platform) parts.push(`Platform: ${issue.platform}`)
|
|
if (issue.count) parts.push(`Events: ${issue.count}`)
|
|
if (issue.userCount != null) parts.push(`Users affected: ${issue.userCount}`)
|
|
if (issue.firstSeen) parts.push(`First seen: ${issue.firstSeen}`)
|
|
if (issue.lastSeen) parts.push(`Last seen: ${issue.lastSeen}`)
|
|
|
|
if (event) {
|
|
const message = event.message?.trim() || event.title?.trim()
|
|
if (message) {
|
|
parts.push('')
|
|
parts.push('--- Latest Event ---')
|
|
if (event.dateCreated) parts.push(`Occurred: ${event.dateCreated}`)
|
|
parts.push(message)
|
|
}
|
|
|
|
const exceptionEntry = event.entries?.find((entry) => entry.type === 'exception')
|
|
if (exceptionEntry?.data) {
|
|
const exceptionLines = formatException(exceptionEntry.data as SentryExceptionData)
|
|
if (exceptionLines.length > 0) {
|
|
parts.push('')
|
|
parts.push('--- Exception ---')
|
|
parts.push(...exceptionLines)
|
|
}
|
|
}
|
|
|
|
const tagLines = (event.tags ?? [])
|
|
.map((tag) => (tag.key && tag.value ? `${tag.key}: ${tag.value}` : undefined))
|
|
.filter((line): line is string => Boolean(line))
|
|
if (tagLines.length > 0) {
|
|
parts.push('')
|
|
parts.push('--- Tags ---')
|
|
parts.push(...tagLines)
|
|
}
|
|
}
|
|
|
|
return parts.join('\n').trim()
|
|
}
|
|
|
|
/**
|
|
* Fetches the latest event for an issue. Returns null when the issue has no events or the
|
|
* request fails, so the document still syncs with its list-level summary.
|
|
*
|
|
* Uses the organization-scoped event endpoint
|
|
* `/api/0/organizations/{org}/issues/{id}/events/latest/`, which is the documented path
|
|
* and works for both sentry.io and self-hosted installs.
|
|
*/
|
|
async function fetchLatestEvent(
|
|
apiBase: string,
|
|
organization: string,
|
|
accessToken: string,
|
|
issueId: string
|
|
): Promise<SentryEvent | null> {
|
|
const url = `${apiBase}/organizations/${encodeURIComponent(organization)}/issues/${encodeURIComponent(issueId)}/events/latest/`
|
|
|
|
const response = await secureFetchWithRetry(url, {
|
|
method: 'GET',
|
|
headers: authHeaders(accessToken),
|
|
})
|
|
|
|
if (!response.ok) {
|
|
if (response.status !== 404) {
|
|
logger.warn('Failed to fetch latest Sentry event', { issueId, status: response.status })
|
|
}
|
|
return null
|
|
}
|
|
|
|
return (await response.json()) as SentryEvent
|
|
}
|
|
|
|
export const sentryConnector: ConnectorConfig = {
|
|
...sentryConnectorMeta,
|
|
|
|
listDocuments: async (
|
|
accessToken: string,
|
|
sourceConfig: Record<string, unknown>,
|
|
cursor?: string,
|
|
syncContext?: Record<string, unknown>
|
|
): Promise<ExternalDocumentList> => {
|
|
const { apiBase, organization, project, query, statsPeriod, environment, maxIssues } =
|
|
readSourceConfig(sourceConfig)
|
|
|
|
if (!organization || !project) {
|
|
throw new Error('Organization and project slugs are required')
|
|
}
|
|
|
|
/*
|
|
* Uses the project issues list endpoint
|
|
* `/api/0/projects/{org}/{project}/issues/`. This endpoint is deprecated in favor of
|
|
* `/api/0/organizations/{org}/issues/?project=<id>`, but the organization endpoint
|
|
* filters by numeric project ID rather than slug — a UX regression for a connector
|
|
* keyed on the human-readable project slug. The project endpoint remains functional
|
|
* and slug-addressable, so it is retained deliberately for the listing path. Issue
|
|
* detail and latest-event fetches use the organization-scoped paths.
|
|
*/
|
|
const url = new URL(
|
|
`${apiBase}/projects/${encodeURIComponent(organization)}/${encodeURIComponent(project)}/issues/`
|
|
)
|
|
url.searchParams.set('query', query)
|
|
url.searchParams.set('limit', String(ISSUES_PER_PAGE))
|
|
if (statsPeriod) url.searchParams.set('statsPeriod', statsPeriod)
|
|
if (environment) url.searchParams.set('environment', environment)
|
|
if (cursor) url.searchParams.set('cursor', cursor)
|
|
|
|
logger.info('Listing Sentry issues', {
|
|
organization,
|
|
project,
|
|
cursor: cursor ?? 'initial',
|
|
maxIssues,
|
|
})
|
|
|
|
const response = await secureFetchWithRetry(url.toString(), {
|
|
method: 'GET',
|
|
headers: authHeaders(accessToken),
|
|
})
|
|
|
|
if (!response.ok) {
|
|
const errorText = await response.text().catch(() => '')
|
|
logger.error('Failed to list Sentry issues', {
|
|
status: response.status,
|
|
error: errorText.slice(0, 500),
|
|
})
|
|
throw new Error(`Failed to list Sentry issues: ${response.status}`)
|
|
}
|
|
|
|
const issues = ((await response.json()) as SentryIssue[]).filter((issue) => Boolean(issue.id))
|
|
|
|
const prevFetched = (syncContext?.totalDocsFetched as number) ?? 0
|
|
let documents = issues.map(issueToStub)
|
|
let slicedSome = false
|
|
if (maxIssues > 0) {
|
|
const remaining = Math.max(0, maxIssues - prevFetched)
|
|
if (documents.length > remaining) {
|
|
slicedSome = true
|
|
documents = documents.slice(0, remaining)
|
|
}
|
|
}
|
|
|
|
const totalFetched = prevFetched + documents.length
|
|
if (syncContext) syncContext.totalDocsFetched = totalFetched
|
|
const hitLimit = maxIssues > 0 && totalFetched >= maxIssues
|
|
|
|
const nextCursor = parseNextCursor(response.headers.get('Link'))
|
|
if (hitLimit && (slicedSome || Boolean(nextCursor)) && syncContext) {
|
|
syncContext.listingCapped = true
|
|
}
|
|
const hasMore = !hitLimit && Boolean(nextCursor)
|
|
|
|
return {
|
|
documents,
|
|
nextCursor: hasMore ? nextCursor : undefined,
|
|
hasMore,
|
|
}
|
|
},
|
|
|
|
getDocument: async (
|
|
accessToken: string,
|
|
sourceConfig: Record<string, unknown>,
|
|
externalId: string
|
|
): Promise<ExternalDocument | null> => {
|
|
try {
|
|
if (!externalId) return null
|
|
|
|
const { apiBase, organization } = readSourceConfig(sourceConfig)
|
|
if (!organization) return null
|
|
|
|
const url = `${apiBase}/organizations/${encodeURIComponent(organization)}/issues/${encodeURIComponent(externalId)}/`
|
|
|
|
const response = await secureFetchWithRetry(url, {
|
|
method: 'GET',
|
|
headers: authHeaders(accessToken),
|
|
})
|
|
|
|
if (!response.ok) {
|
|
if (response.status === 404 || response.status === 410) return null
|
|
throw new Error(`Failed to fetch Sentry issue: ${response.status}`)
|
|
}
|
|
|
|
const issue = (await response.json()) as SentryIssue
|
|
if (!issue?.id) return null
|
|
|
|
const event = await fetchLatestEvent(apiBase, organization, accessToken, issue.id)
|
|
const content = formatIssueContent(issue, event)
|
|
if (!content.trim()) return null
|
|
|
|
return {
|
|
externalId: issue.id,
|
|
title: buildTitle(issue),
|
|
content,
|
|
contentDeferred: false,
|
|
mimeType: 'text/plain',
|
|
sourceUrl: issue.permalink || undefined,
|
|
contentHash: buildContentHash(issue),
|
|
metadata: buildMetadata(issue),
|
|
}
|
|
} catch (error) {
|
|
logger.warn('Failed to get Sentry issue', {
|
|
externalId,
|
|
error: toError(error).message,
|
|
})
|
|
return null
|
|
}
|
|
},
|
|
|
|
validateConfig: async (
|
|
accessToken: string,
|
|
sourceConfig: Record<string, unknown>
|
|
): Promise<{ valid: boolean; error?: string }> => {
|
|
const { apiBase, organization, project, statsPeriod, maxIssues, host } =
|
|
readSourceConfig(sourceConfig)
|
|
|
|
if (!organization) {
|
|
return { valid: false, error: 'Organization slug is required' }
|
|
}
|
|
if (!project) {
|
|
return { valid: false, error: 'Project slug is required' }
|
|
}
|
|
|
|
if (statsPeriod && !ALLOWED_STATS_PERIODS.has(statsPeriod)) {
|
|
return { valid: false, error: 'Stats period must be 24h or 14d' }
|
|
}
|
|
|
|
const rawMax = sourceConfig.maxIssues as string | undefined
|
|
if (rawMax && (Number.isNaN(maxIssues) || maxIssues < 0)) {
|
|
return { valid: false, error: 'Max issues must be a non-negative number' }
|
|
}
|
|
|
|
try {
|
|
/*
|
|
* Probe the project detail endpoint first. This exercises the `project:read`
|
|
* scope and the project-scoped path style, and gives a precise "not found"
|
|
* message when the org or project slug is wrong.
|
|
*/
|
|
const projectResponse = await secureFetchWithRetry(
|
|
`${apiBase}/projects/${encodeURIComponent(organization)}/${encodeURIComponent(project)}/`,
|
|
{
|
|
method: 'GET',
|
|
headers: authHeaders(accessToken),
|
|
},
|
|
VALIDATE_RETRY_OPTIONS
|
|
)
|
|
|
|
if (!projectResponse.ok) {
|
|
if (projectResponse.status === 401 || projectResponse.status === 403) {
|
|
return { valid: false, error: 'Invalid auth token or insufficient permissions' }
|
|
}
|
|
if (projectResponse.status === 404) {
|
|
return {
|
|
valid: false,
|
|
error: `Organization or project not found on ${host}`,
|
|
}
|
|
}
|
|
const errorText = await projectResponse.text().catch(() => '')
|
|
return {
|
|
valid: false,
|
|
error: `Sentry access failed: ${projectResponse.status}${errorText ? ` — ${errorText.slice(0, 200)}` : ''}`,
|
|
}
|
|
}
|
|
|
|
/*
|
|
* Probe the issues-list endpoint with a single-result page. The project
|
|
* detail probe above only proves `project:read`, but every sync operation —
|
|
* `listDocuments` and the org-scoped `getDocument`/latest-event hydration —
|
|
* needs `event:read`. A token scoped to `project:read` only would pass the
|
|
* first probe yet fail at hydration time, so this second probe forces a
|
|
* misconfigured token to fail fast at save time. It is slug-addressable and
|
|
* cheap (one issue, no stats window).
|
|
*/
|
|
const issuesProbeUrl = new URL(
|
|
`${apiBase}/projects/${encodeURIComponent(organization)}/${encodeURIComponent(project)}/issues/`
|
|
)
|
|
issuesProbeUrl.searchParams.set('query', DEFAULT_QUERY)
|
|
issuesProbeUrl.searchParams.set('limit', '1')
|
|
|
|
const issuesResponse = await secureFetchWithRetry(
|
|
issuesProbeUrl.toString(),
|
|
{
|
|
method: 'GET',
|
|
headers: authHeaders(accessToken),
|
|
},
|
|
VALIDATE_RETRY_OPTIONS
|
|
)
|
|
|
|
if (!issuesResponse.ok) {
|
|
if (issuesResponse.status === 401 || issuesResponse.status === 403) {
|
|
return {
|
|
valid: false,
|
|
error:
|
|
'Auth token cannot read issues. The token needs the "event:read" scope (in addition to "project:read").',
|
|
}
|
|
}
|
|
const errorText = await issuesResponse.text().catch(() => '')
|
|
return {
|
|
valid: false,
|
|
error: `Sentry issue access failed: ${issuesResponse.status}${errorText ? ` — ${errorText.slice(0, 200)}` : ''}`,
|
|
}
|
|
}
|
|
|
|
return { valid: true }
|
|
} catch (error) {
|
|
const message = getErrorMessage(error, 'Failed to validate configuration')
|
|
return { valid: false, error: message }
|
|
}
|
|
},
|
|
|
|
mapTags: (metadata: Record<string, unknown>): Record<string, unknown> => {
|
|
const result: Record<string, unknown> = {}
|
|
|
|
if (typeof metadata.level === 'string' && metadata.level.trim()) {
|
|
result.level = metadata.level
|
|
}
|
|
|
|
if (typeof metadata.status === 'string' && metadata.status.trim()) {
|
|
result.status = metadata.status
|
|
}
|
|
|
|
if (metadata.count != null) {
|
|
const num = Number(metadata.count)
|
|
if (!Number.isNaN(num)) result.count = num
|
|
}
|
|
|
|
const firstSeen = parseTagDate(metadata.firstSeen)
|
|
if (firstSeen) result.firstSeen = firstSeen
|
|
|
|
const lastSeen = parseTagDate(metadata.lastSeen)
|
|
if (lastSeen) result.lastSeen = lastSeen
|
|
|
|
return result
|
|
},
|
|
}
|