From b76d04d6a0ebc272a8587cc691294e3025830e3a Mon Sep 17 00:00:00 2001 From: Hieu Date: Thu, 23 Jul 2026 12:17:45 +0700 Subject: [PATCH 1/8] fix: read FGA permissions from openapi spec x-fga-permissions extension (#48181) ## I have read the [CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md) file. YES ## What kind of change does this PR introduce? Follow up to https://github.com/supabase/platform/pull/35940, which moved FGA permissions off security and onto the `x-fga-permissions` extension. This updates the docs reference component to read from the new field. ## Summary by CodeRabbit * **New Features** * API documentation now supports displaying fine-grained access permissions for endpoints. * Endpoint security details are presented more consistently using the documented permissions configuration. --- apps/docs/features/docs/Reference.api.utils.ts | 1 + apps/docs/features/docs/Reference.sections.tsx | 5 +---- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/apps/docs/features/docs/Reference.api.utils.ts b/apps/docs/features/docs/Reference.api.utils.ts index 480da7d88d4c5..2d87c9205085d 100644 --- a/apps/docs/features/docs/Reference.api.utils.ts +++ b/apps/docs/features/docs/Reference.api.utils.ts @@ -26,6 +26,7 @@ export interface IApiEndPoint { security?: Array 'x-oauth-scope'?: string 'x-allowed-plans'?: string[] + 'x-fga-permissions'?: string[][] } export type ISchema = diff --git a/apps/docs/features/docs/Reference.sections.tsx b/apps/docs/features/docs/Reference.sections.tsx index 98654285c451e..cbced0f844c01 100644 --- a/apps/docs/features/docs/Reference.sections.tsx +++ b/apps/docs/features/docs/Reference.sections.tsx @@ -275,10 +275,7 @@ async function ApiEndpointSection({ link, section, servicePath }: ApiEndpointSec : await getApiEndpointById(section.id) if (!endpointDetails) return null - const endpointFgaPermissionGroups = - endpointDetails.security - ?.filter((sec) => 'fga_permissions' in sec) - .map((sec) => sec.fga_permissions) ?? [] + const endpointFgaPermissionGroups = endpointDetails['x-fga-permissions'] ?? [] const pathParameters = (endpointDetails.parameters ?? []).filter((param) => param.in === 'path') const queryParameters = (endpointDetails.parameters ?? []).filter((param) => param.in === 'query') const bodyParameters = From 63e2eb3ca6e98f4285c592c0a32a4a7fd497f194 Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Thu, 23 Jul 2026 15:19:54 +0800 Subject: [PATCH 2/8] Joshen/fe 3971 blocked by visualization (#48187) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Context Improving the "blocked by" visualisation for database connections - to accommodate the situation whereby there might be a chain of blocked process. Intention is so that users can identify whats the root process that's blocking everything - and from there decide if they want to terminate the process or not. Also brings `ActivityRow` out into its separate file since `Activity` is getting big image ## Summary by CodeRabbit * **Refactor** * Streamlined the database activity view by separating the single-row rendering into its own component, keeping the same end-user experience (status badge, query/“No query”, duration warnings, blocking details, PID copy, and actions). * Kept “Terminate” behind confirmation prompts and preserved role-based restrictions for when termination is available. * **Improvements** * Standardized how activity durations are calculated and how status badges are styled for consistent display. --- .../DatabaseConnections/Activity.tsx | 298 +-------------- .../DatabaseConnections/ActivityRow.tsx | 338 ++++++++++++++++++ .../DatabaseConnections.utils.ts | 27 ++ 3 files changed, 370 insertions(+), 293 deletions(-) create mode 100644 apps/studio/components/interfaces/Observability/DatabaseConnections/ActivityRow.tsx create mode 100644 apps/studio/components/interfaces/Observability/DatabaseConnections/DatabaseConnections.utils.ts diff --git a/apps/studio/components/interfaces/Observability/DatabaseConnections/Activity.tsx b/apps/studio/components/interfaces/Observability/DatabaseConnections/Activity.tsx index 3c1ed319c4396..53a00edfb1dfe 100644 --- a/apps/studio/components/interfaces/Observability/DatabaseConnections/Activity.tsx +++ b/apps/studio/components/interfaces/Observability/DatabaseConnections/Activity.tsx @@ -1,81 +1,16 @@ -import dayjs from 'dayjs' import { isEqual } from 'lodash' -import { Minus, MoreVertical, StopCircle, X } from 'lucide-react' +import { X } from 'lucide-react' import { parseAsArrayOf, parseAsInteger, parseAsString, useQueryState, useQueryStates } from 'nuqs' -import { Fragment, useEffect, useState } from 'react' -import { toast } from 'sonner' -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, - Badge, - Button, - Card, - cn, - copyToClipboard, - DropdownMenu, - DropdownMenuContent, - DropdownMenuTrigger, - HoverCard, - HoverCardContent, - HoverCardTrigger, - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, - Tooltip, - TooltipContent, - TooltipTrigger, -} from 'ui' -import { CodeBlock } from 'ui-patterns/CodeBlock' +import { useEffect } from 'react' +import { Button, Card, Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from 'ui' import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' import { ReportsSelectFilter } from '../../Reports/v2/ReportsSelectFilter' -import { - QUERY_STATE_TOOLTIP, - WARN_DURATION_ACTIVE_QUERY, - WARN_DURATION_IDLE_TXN, -} from './DatabaseConnections.constants' -import { formatDuration } from '@/components/interfaces/QueryPerformance/QueryPerformance.utils' +import { ActivityRow } from './ActivityRow' import { ButtonTooltip } from '@/components/ui/ButtonTooltip' -import { DropdownMenuItemTooltip } from '@/components/ui/DropdownMenuItemTooltip' -import { InlineLinkClassName } from '@/components/ui/InlineLink' import { useDatabaseRolesQuery } from '@/data/database-roles/database-roles-query' -import { useDatabaseActivityQuery, type DatabaseActivity } from '@/data/database/activity-query' -import { useQueryAbortMutation } from '@/data/sql/abort-query-mutation' +import { useDatabaseActivityQuery } from '@/data/database/activity-query' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' -import { formatSql } from '@/lib/formatSql' - -const getDuration = (activity: DatabaseActivity) => { - const { state } = activity - if (state === 'active' && activity.query_start) { - return dayjs().utc().diff(dayjs(activity.query_start).utc(), 'second') - } - if (state === 'idle' && activity.state_change) { - return dayjs().utc().diff(dayjs(activity.state_change).utc(), 'second') - } - if ( - (state === 'idle in transaction' || state === 'idle in transaction (aborted)') && - activity.transaction_start - ) { - return dayjs().utc().diff(dayjs(activity.transaction_start).utc(), 'second') - } - return null -} - -const getBadgeVariant = (activity: DatabaseActivity) => { - const { state } = activity - if (state === 'active') return 'success' - if (state === 'idle in transaction' || state === 'idle in transaction (aborted)') return 'warning' - return 'default' -} const DEFAULT_ROLES_FILTER = ['anon', 'authenticated', 'postgres'] @@ -312,226 +247,3 @@ export const Activity = ({ live }: ActivityProps) => { ) } - -const ActivityRow = ({ activity }: { activity: DatabaseActivity }) => { - const { data: project } = useSelectedProjectQuery() - const [showTerminateConfirmDialog, setShowTerminateConfirmDialog] = useState(false) - const [selectedPid, setSelectedPid] = useQueryState('pid', parseAsInteger) - - const { data } = useDatabaseActivityQuery({ - projectRef: project?.ref, - connectionString: project?.connectionString, - }) - - const { data: roles } = useDatabaseRolesQuery({ - projectRef: project?.ref, - connectionString: project?.connectionString, - }) - const superuserRoles = roles?.filter((role) => role.isSuperuser).map((role) => role.name) - - const { mutateAsync: abortQuery } = useQueryAbortMutation({ - onSuccess: () => { - toast.success(`Successfully aborted query (ID: ${activity.pid})`) - }, - }) - - const durationSeconds = getDuration(activity) - const badgeVariant = getBadgeVariant(activity) - - /** - * Queries in "active state": 30s threshold is long enough (most CRUD queries should be quick) - * Queries in "idle in transaction" state: This actively holds locks and blocks autovacuum while contributing nothing, so important to surface early at 10s threshold - */ - const queryRunningLongWarning = - !!durationSeconds && - ((activity.state === 'active' && durationSeconds >= WARN_DURATION_ACTIVE_QUERY) || - ((activity.state === 'idle in transaction' || - activity.state === 'idle in transaction (aborted)') && - durationSeconds >= WARN_DURATION_IDLE_TXN)) - - const onConfirmTerminate = async () => { - try { - await abortQuery({ - pid: activity.pid, - projectRef: project?.ref, - connectionString: project?.connectionString, - }) - } catch (error) {} - } - - return ( - <> - - - {selectedPid === activity.pid && ( -
- )} - - - {activity.state} - - {activity.state && ( - {QUERY_STATE_TOOLTIP[activity.state]} - )} - -
- - - -

- {!!activity.query ? activity.query : 'No query'} -

-
- {activity.query && ( - - code]:text-xs max-h-64', - '[&>code]:m-0 [&>code>span]:flex [&>code>span]:flex-wrap min-h-11' - )} - wrapperClassName={cn('[&_pre]:px-4 [&_pre]:py-0')} - language="pgsql" - value={formatSql(activity.query)} - /> - - )} -
-
- - { - toast.success('Copied PID') - copyToClipboard(activity.pid.toString()) - }} - > - PID: {activity.pid} - - Click to copy - - · - {activity.role_name} - {activity.application_name && ( - <> - · - {activity.application_name} - - )} -
-
- - -

- {durationSeconds !== null ? ( - formatDuration(durationSeconds * 1000, 0) - ) : ( - - )} -

-
- - - {activity.blocked_by.length > 0 ? ( - activity.blocked_by.map((pid, index) => { - const blockedProcess = data?.find((x) => x.pid === pid) - - return ( - - {index > 0 && ', '} - - setSelectedPid(pid)} - > - {pid} - - -

- {blockedProcess?.query} -

-

- PID: {blockedProcess?.pid} - · - {blockedProcess?.role_name} - {blockedProcess?.application_name && ( - <> - · - {blockedProcess?.application_name} - - )} -

-
-
-
- ) - }) - ) : ( - - )} -
- - - - - + ) + })} + + +
+
+

+ Products +

+
{CHANGELOG_PRODUCT_TAGS.map(({ slug, label }) => { const on = selectedTags.has(slug) return (
+
+
+

+ Product stage +

+
+ {CHANGELOG_PRODUCT_STAGES.map(({ slug, label }) => { + const on = selectedStages.has(slug) + return ( + + ) + })} +
+
+
+
+

+ Self-hosted +

+
+ {CHANGELOG_SELF_HOSTED_OPTIONS.map(({ slug, label }) => { + const on = selectedSelfHosted.has(slug) + return ( + + ) + })} +
+
)} @@ -358,8 +493,8 @@ function ChangelogIndex({ featured, restIndex, allIndex }: PageProps) {
{featured.map((entry) => (
@@ -375,24 +510,12 @@ function ChangelogIndex({ featured, restIndex, allIndex }: PageProps) { )} -

- {dayjs(entry.created_at).format('MMM D, YYYY')} -

- {entry.labels && entry.labels.length > 0 && ( -
- {entry.labels.map((label) => ( - - - {changelogLabelDisplayName(label.name)} - - - ))} -
- )} +
+

+ {dayjs(entry.created_at).format('MMM D, YYYY')} +

+
+
@@ -404,6 +527,21 @@ function ChangelogIndex({ featured, restIndex, allIndex }: PageProps) { )} + {entry.affectedProducts.length > 0 && ( +
+ {entry.affectedProducts.map((product) => ( + + + {product} + + + ))} +
+ )}
))} @@ -416,7 +554,7 @@ function ChangelogIndex({ featured, restIndex, allIndex }: PageProps) { )}
-
+
( +const ChangelogDetailPage = ({ title, created_at, slug, frontmatter, source }: PageProps) => ( <> @@ -54,16 +46,18 @@ const ChangelogDetailPage = ({ title, url, created_at, slug, source, labels }: P

{title}

-

- {dayjs(created_at).format('MMM D, YYYY')} -

+
+

+ {dayjs(created_at).format('MMM D, YYYY')} +

+
{'error' in source ? ( -

Error rendering blog post: {source.error.message}

+

Error rendering changelog entry: {source.error.message}

) : ( )} @@ -72,7 +66,7 @@ const ChangelogDetailPage = ({ title, url, created_at, slug, source, labels }: P
@@ -88,53 +82,23 @@ export const getStaticPaths: GetStaticPaths = async () => { export const getStaticProps: GetStaticProps = async ({ params }) => { const raw = params?.slug - const slugStr = Array.isArray(raw) ? raw[0] : (raw ?? '') - // The slug always starts with the numeric discussion number. - const number = parseInt(slugStr, 10) - if (!Number.isFinite(number) || number <= 0) return { notFound: true } - - try { - const octokit = createChangelogOctokit() - const discussion = await fetchChangelogDiscussionByNumber( - octokit, - 'supabase', - 'supabase', - number - ) - - if (!discussion || discussion.category?.id !== CHANGELOG_CATEGORY_ID) { - return { notFound: true } - } - - const expectedSlug = changelogEntrySlug(number, discussion.title) + const slug = Array.isArray(raw) ? raw[0] : (raw ?? '') - // Redirect number-only or mismatched slugs to the canonical slug URL. - if (slugStr !== expectedSlug) { - return { redirect: { destination: `/changelog/${expectedSlug}`, permanent: true } } - } + const entries = await getChangelogEntries() + const entry = entries.find((e) => e.slug === slug) + if (!entry) return { notFound: true } - const source = await mdxSerialize(discussion.body) - const created_at = - discussionDisplayDate({ - title: discussion.title, - createdAt: discussion.createdAt, - }) ?? discussion.createdAt + const source = await mdxSerialize(entry.bodySection) - return { - props: { - title: discussion.title, - url: discussion.url, - created_at, - number, - slug: expectedSlug, - source, - labels: discussion.labels?.nodes ?? [], - }, - revalidate: 900, - } - } catch (e) { - console.error(e) - return { notFound: true } + return { + props: { + title: entry.frontmatter.title, + created_at: entry.sortDate, + slug: entry.slug, + frontmatter: entry.frontmatter, + source, + }, + revalidate: 900, } } diff --git a/apps/www/scripts/data/changelog-deleted-discussions.json b/apps/www/scripts/data/changelog-deleted-discussions.json deleted file mode 100644 index d4d97616816a1..0000000000000 --- a/apps/www/scripts/data/changelog-deleted-discussions.json +++ /dev/null @@ -1,21 +0,0 @@ -[ - { "title": "Platform updates: October 2023", "createdAt": "2023-11-06T16:25:12Z" }, - { "title": "Platform update September 2023", "createdAt": "2023-10-06T09:23:56Z" }, - { "title": "Platform updates: August 2023", "createdAt": "2023-09-08T13:00:39Z" }, - { "title": "Platform updates June 2023", "createdAt": "2023-07-07T16:09:32Z" }, - { "title": "Platform updates: May 2023", "createdAt": "2023-06-09T16:40:16Z" }, - { "title": "Platform updates: April 2023", "createdAt": "2023-05-10T18:40:02Z" }, - { "title": "Platform Update February 2023", "createdAt": "2023-03-09T12:06:01Z" }, - { "title": "Platform update January 2023", "createdAt": "2023-02-08T18:29:41Z" }, - { "title": "Platform Updates November 2022", "createdAt": "2022-12-08T12:00:48Z" }, - { "title": "Platform updates: October 2022", "createdAt": "2022-11-02T16:07:47Z" }, - { "title": "Platform Update September 2022", "createdAt": "2022-10-07T10:18:21Z" }, - { "title": "Platform updates: 30 Nov 2021", "createdAt": "2021-11-30T10:06:55Z" }, - { "title": "October Beta 2021", "createdAt": "2021-11-08T13:14:35Z" }, - { "title": "September Beta 2021", "createdAt": "2021-10-04T18:10:57Z" }, - { "title": "August Beta 2021", "createdAt": "2021-09-13T09:47:18Z" }, - { "title": "July Beta 2021", "createdAt": "2021-08-12T13:46:14Z" }, - { "title": "June Beta 2021", "createdAt": "2021-07-04T13:23:35Z" }, - { "title": "May Beta 2021", "createdAt": "2021-06-10T11:53:14Z" }, - { "title": "April Beta 2021", "createdAt": "2021-05-05T05:17:27Z" } -] diff --git a/apps/www/scripts/generateStaticContent.mjs b/apps/www/scripts/generateStaticContent.mjs index 0fac6a1eaed11..04f694b38c0a6 100644 --- a/apps/www/scripts/generateStaticContent.mjs +++ b/apps/www/scripts/generateStaticContent.mjs @@ -378,198 +378,140 @@ try { console.warn('Error generating RSS feed:', error) } -// Changelog RSS → public/changelog-rss.xml (same GitHub App + category as lib/changelog-github.ts) -try { - const appId = process.env.GITHUB_CHANGELOG_APP_ID - const installationId = process.env.GITHUB_CHANGELOG_APP_INSTALLATION_ID - const privateKey = process.env.GITHUB_CHANGELOG_APP_PRIVATE_KEY +// Changelog RSS + changelog.md → sourced from supabase/changelog entries/*.md (private repo). +// Missing secret: warns and skips outside Vercel, but fails Vercel builds so they can't +// publish stale generated changelog files. Generic CI (typecheck/lint on GitHub Actions) +// has no access to this secret and isn't publishing anything, so it only warns too. +async function generateChangelogContent() { + const appId = process.env.CHANGELOG_SYNC_APP_ID + const installationId = process.env.CHANGELOG_SYNC_APP_INSTALLATION_ID + const privateKey = process.env.CHANGELOG_SYNC_APP_PRIVATE_KEY if (!appId || !installationId || !privateKey) { - console.warn('Skipping changelog RSS: missing GITHUB_CHANGELOG_APP_* env vars') - } else { - const { createAppAuth } = await import('@octokit/auth-app') - const { Octokit } = await import('@octokit/core') - const { paginateGraphql } = await import('@octokit/plugin-paginate-graphql') - - const { generateChangelogRssXml, generateChangelogTagRssXml, labelToFileSlug, changelogEntrySlug } = - await import('../lib/changelog-rss.mjs') - const rewritesPath = path.join(__dirname, 'data/changelog-deleted-discussions.json') - const rewrites = JSON.parse(await fs.readFile(rewritesPath, 'utf8')) - const discussionDisplayDate = (item) => { - const dateRewrite = rewrites.find( - (r) => item.title && r.title && item.title.includes(r.title) - ) - return dateRewrite ? dateRewrite.createdAt : item.createdAt + if (process.env.VERCEL) { + throw new Error('CHANGELOG_SYNC_APP_* env vars not set — cannot generate changelog content') } + console.warn('⚠️ CHANGELOG_SYNC_APP_* env vars not set — skipping changelog RSS/md generation') + return + } - const CHANGELOG_CATEGORY_ID = 'DIC_kwDODMpXOc4CAFUr' - - const changelogQuery = ` - query changelogDiscussionMetadata($cursor: String, $owner: String!, $repo: String!, $categoryId: ID!) { - repository(owner: $owner, name: $repo) { - discussions( - first: 100 - after: $cursor - categoryId: $categoryId - orderBy: { field: CREATED_AT, direction: DESC } - ) { - nodes { - number - title - body - createdAt - url - labels(first: 25) { - nodes { - name - } - } - } - pageInfo { - hasNextPage - endCursor - } - } - } - } - ` - - const ExtendedOctokit = Octokit.plugin(paginateGraphql) - const octokit = new ExtendedOctokit({ - authStrategy: createAppAuth, - auth: { - appId, - installationId, - privateKey: privateKey.replace(/\\n/g, '\n'), - }, - }) + const { getPublishedChangelogEntries, fetchChangelogEntryFilesFromTarball, CHANGE_TYPE_LABELS } = + await import('../lib/changelog-entries-core.mjs') + const { generateChangelogRssXml, generateChangelogTagRssXml, labelToFileSlug } = await import( + '../lib/changelog-rss.mjs' + ) + const { createAppAuth } = await import('@octokit/auth-app') + const { Octokit } = await import('@octokit/core') + const octokit = new Octokit({ + authStrategy: createAppAuth, + auth: { appId, installationId, privateKey: privateKey.replace(/\\n/g, '\n') }, + }) - const collected = [] - let cursor = null - let hasNextPage = true - while (hasNextPage) { - const { - repository: { - discussions: { nodes, pageInfo }, - }, - } = await octokit.graphql(changelogQuery, { - owner: 'supabase', - repo: 'supabase', - categoryId: CHANGELOG_CATEGORY_ID, - cursor, - }) - collected.push(...nodes) - hasNextPage = pageInfo.hasNextPage - cursor = pageInfo.endCursor + // Single tarball request — fetching each entry file individually trips + // GitHub's secondary rate limit once the entries directory gets large. + const files = await fetchChangelogEntryFilesFromTarball(octokit, { + owner: 'supabase', + repo: 'changelog', + entriesPath: 'entries', + }) + const entries = getPublishedChangelogEntries(files) + + const rssEntries = entries.map((entry) => ({ + slug: entry.slug, + title: entry.frontmatter.title, + sortDate: entry.sortDate, + affectedProducts: entry.frontmatter.affected_products ?? [], + })) + + const changelogXml = generateChangelogRssXml(rssEntries) + const changelogRssPath = path.join(__dirname, '../public/changelog-rss.xml') + await fs.writeFile(changelogRssPath, changelogXml.trim(), 'utf8') + console.log(`✅ Generated changelog RSS with ${entries.length} entries`) + + // Per-tag feeds → public/changelog-rss/.xml + const productTagsPath = path.join(__dirname, '../data/changelog-product-tags.json') + const productTags = JSON.parse(await fs.readFile(productTagsPath, 'utf8')) + const tagFeedsDir = path.join(__dirname, '../public/changelog-rss') + // Clear first so a renamed/removed product tag doesn't leave a stale feed file behind. + await fs.rm(tagFeedsDir, { recursive: true, force: true }) + await fs.mkdir(tagFeedsDir, { recursive: true }) + const tagFilenames = productTags.map(({ label }) => `${labelToFileSlug(label)}.xml`) + const tagResults = await Promise.allSettled( + productTags.map(async ({ label }) => { + const fileSlug = labelToFileSlug(label) + const tagXml = generateChangelogTagRssXml(rssEntries, { displayLabel: label }) + await fs.writeFile(path.join(tagFeedsDir, `${fileSlug}.xml`), tagXml.trim(), 'utf8') + }) + ) + const failedTagFeeds = tagResults.flatMap((result, i) => + result.status === 'rejected' ? [{ file: tagFilenames[i], reason: result.reason }] : [] + ) + const succeeded = tagResults.length - failedTagFeeds.length + console.log(`✅ Generated ${succeeded}/${productTags.length} per-tag changelog RSS feeds`) + if (failedTagFeeds.length > 0) { + for (const { file, reason } of failedTagFeeds) { + console.error(`Failed to write changelog-rss/${file}:`, reason) } - - const entries = collected.map((item) => ({ - number: item.number, - slug: changelogEntrySlug(item.number, item.title), - title: item.title, - url: item.url, - sortDate: discussionDisplayDate({ title: item.title, createdAt: item.createdAt }), - labels: (item.labels?.nodes ?? []).map((l) => l.name.toLowerCase()), - body: item.body ?? '', - })) - - const changelogXml = generateChangelogRssXml(entries) - const changelogRssPath = path.join(__dirname, '../public/changelog-rss.xml') - await fs.writeFile(changelogRssPath, changelogXml.trim(), 'utf8') - const visibleCount = entries.filter((e) => !e.title.includes('[d]')).length - console.log(`✅ Generated changelog RSS with ${visibleCount} entries`) - - // Per-tag feeds → public/changelog-rss/.xml - const productTagsPath = path.join(__dirname, '../data/changelog-product-tags.json') - const productTags = JSON.parse(await fs.readFile(productTagsPath, 'utf8')) - const tagFeedsDir = path.join(__dirname, '../public/changelog-rss') - await fs.mkdir(tagFeedsDir, { recursive: true }) - const tagResults = await Promise.allSettled( - productTags.map(async ({ slug, label }) => { - const fileSlug = labelToFileSlug(label) - const tagXml = generateChangelogTagRssXml(entries, { - githubLabelSlug: slug, - displayLabel: label, - }) - await fs.writeFile(path.join(tagFeedsDir, `${fileSlug}.xml`), tagXml.trim(), 'utf8') - }) + throw new Error( + `Failed to generate ${failedTagFeeds.length}/${productTags.length} per-tag changelog RSS feeds` ) - const succeeded = tagResults.filter((r) => r.status === 'fulfilled').length - console.log(`✅ Generated ${succeeded}/${productTags.length} per-tag changelog RSS feeds`) - - // LLM-friendly changelog markdown index (RSS remains canonical syndication format). - const visibleEntries = entries.filter((entry) => !entry.title.includes('[d]')) - - /** - * Extracts the first meaningful paragraph from a markdown body. - * Skips headings, code fences, HTML blocks, and empty lines. - */ - const extractSummary = (body) => { - if (!body) return '' - for (const para of body.split(/\n{2,}/)) { - const trimmed = para.trim() - if ( - !trimmed || - trimmed.startsWith('#') || - trimmed.startsWith('```') || - trimmed.startsWith('<') || - trimmed.startsWith('|') || - trimmed.startsWith('---') - ) continue - const oneLiner = trimmed.replace(/\n/g, ' ') - return oneLiner.length > 200 ? oneLiner.slice(0, 200).replace(/\s+\S*$/, '') + '…' : oneLiner - } - return '' - } + } - const mdSections = visibleEntries.map((entry) => { - const date = dayjs(entry.sortDate).isValid() ? dayjs(entry.sortDate).format('YYYY-MM-DD') : '' - const labels = (entry.labels ?? []).join(', ') - const meta = [date, labels, `[supabase.com/changelog/${entry.slug}](https://supabase.com/changelog/${entry.slug})`] - .filter(Boolean) - .join(' · ') - const summary = extractSummary(entry.body) - return [`## ${entry.title}`, meta, summary].filter(Boolean).join('\n\n') - }) - const changelogMd = `# Supabase Changelog\n\n${mdSections.join('\n\n---\n\n')}\n` - const changelogMdPath = path.join(__dirname, '../public/changelog.md') - await fs.writeFile(changelogMdPath, changelogMd, 'utf8') - console.log(`✅ Generated changelog.md (${visibleEntries.length} entries)`) - - // One markdown file per entry → /changelog/.md (same content shape as the web page body). - const changelogEntryMdDir = path.join(__dirname, '../public/changelog') - await fs.mkdir(changelogEntryMdDir, { recursive: true }) - for (const entry of visibleEntries) { - const published = dayjs(entry.sortDate).isValid() - ? dayjs(entry.sortDate).format('YYYY-MM-DD') - : '' - const titleLine = String(entry.title ?? '') - .replace(/\n/g, ' ') - .trim() - const labelsYaml = (entry.labels ?? []).map((l) => ` - ${l}`).join('\n') - const pageUrl = `https://supabase.com/changelog/${entry.slug}` - const entryMd = `--- -number: ${entry.number} + // LLM-friendly changelog markdown index (RSS remains canonical syndication format). + const mdSections = entries.map((entry) => { + const date = dayjs(entry.sortDate).isValid() ? dayjs(entry.sortDate).format('YYYY-MM-DD') : '' + const changeType = CHANGE_TYPE_LABELS[entry.frontmatter.change_type] ?? entry.frontmatter.change_type + const products = (entry.frontmatter.affected_products ?? []).join(', ') + const meta = [ + date, + changeType, + products, + `[supabase.com/changelog/${entry.slug}](https://supabase.com/changelog/${entry.slug})`, + ] + .filter(Boolean) + .join(' · ') + return [`## ${entry.frontmatter.title}`, meta, entry.summary].filter(Boolean).join('\n\n') + }) + const changelogMd = `# Supabase Changelog\n\n${mdSections.join('\n\n---\n\n')}\n` + const changelogMdPath = path.join(__dirname, '../public/changelog.md') + await fs.writeFile(changelogMdPath, changelogMd, 'utf8') + console.log(`✅ Generated changelog.md (${entries.length} entries)`) + + // One markdown file per entry → /changelog/.md (Body section only — internal notes never included). + const changelogEntryMdDir = path.join(__dirname, '../public/changelog') + // Clear first so a renamed/unpublished entry doesn't leave a stale file behind. + await fs.rm(changelogEntryMdDir, { recursive: true, force: true }) + await fs.mkdir(changelogEntryMdDir, { recursive: true }) + for (const entry of entries) { + const published = dayjs(entry.sortDate).isValid() + ? dayjs(entry.sortDate).format('YYYY-MM-DD') + : '' + const titleLine = String(entry.frontmatter.title ?? '') + .replace(/\n/g, ' ') + .trim() + const productsYaml = (entry.frontmatter.affected_products ?? []) + .map((p) => ` - ${p}`) + .join('\n') + const pageUrl = `https://supabase.com/changelog/${entry.slug}` + const entryMd = `--- slug: ${entry.slug} published: ${published} -discussion: ${entry.url} -labels: -${labelsYaml || ' []'} +change_type: ${entry.frontmatter.change_type} +affected_products: +${productsYaml || ' []'} page: ${pageUrl} --- # ${titleLine} -${entry.body ?? ''} +${entry.bodySection} ` - await fs.writeFile( - path.join(changelogEntryMdDir, `${entry.slug}.md`), - entryMd.trim() + '\n', - 'utf8' - ) - } - console.log(`✅ Generated changelog/*.md (${visibleEntries.length} files)`) + await fs.writeFile( + path.join(changelogEntryMdDir, `${entry.slug}.md`), + entryMd.trim() + '\n', + 'utf8' + ) } -} catch (error) { - console.warn('Error generating changelog RSS:', error) + console.log(`✅ Generated changelog/*.md (${entries.length} files)`) } +await generateChangelogContent() diff --git a/apps/www/turbo.jsonc b/apps/www/turbo.jsonc index e4ba1802468af..728b752ffc124 100644 --- a/apps/www/turbo.jsonc +++ b/apps/www/turbo.jsonc @@ -57,6 +57,9 @@ "GITHUB_CHANGELOG_APP_INSTALLATION_ID", "GITHUB_CHANGELOG_APP_REST_KEY", "GITHUB_CHANGELOG_APP_PRIVATE_KEY", + "CHANGELOG_SYNC_APP_ID", + "CHANGELOG_SYNC_APP_INSTALLATION_ID", + "CHANGELOG_SYNC_APP_PRIVATE_KEY", "NEXT_PUBLIC_EMAIL_ABUSE_URL", "EMAIL_ABUSE_SERVICE_KEY", "HUBSPOT_PORTAL_ID", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 783197513ee17..95702943339d4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1932,6 +1932,9 @@ importers: tailwindcss: specifier: 'catalog:' version: 4.2.4 + tar-stream: + specifier: ^3.1.7 + version: 3.1.7 tsconfig: specifier: workspace:* version: link:../../packages/tsconfig From d900c09e8a0314ecfef2f9dbc0e98a986704ff73 Mon Sep 17 00:00:00 2001 From: Francesco Sansalvadore Date: Thu, 23 Jul 2026 11:43:22 +0200 Subject: [PATCH 8/8] chore(studio): grafana-cloud slug (#48238) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use `grafana-cloud` slug for the partner integration. ## Summary by CodeRabbit * **New Features** * Added Grafana Cloud to the featured integrations in the marketplace. * Added light and dark themed artwork for the Grafana Cloud featured integration. * **Improvements** * Updated the OAuth “installed” detection to include Grafana Cloud alongside Grafana. * Included Grafana Cloud in the set of official partners for the partner-only filter. * **Tests** * Expanded marketplace integration fixtures to cover Grafana Cloud scenarios. --- .../Integrations/Landing/Landing.utils.ts | 2 +- .../Landing/MarketplaceDetail.test.tsx | 15 +++++++++++++++ .../Marketplace/Marketplace.constants.tsx | 1 + .../Marketplace/MarketplaceFeaturedHeroGrid.tsx | 4 ++++ 4 files changed, 21 insertions(+), 1 deletion(-) diff --git a/apps/studio/components/interfaces/Integrations/Landing/Landing.utils.ts b/apps/studio/components/interfaces/Integrations/Landing/Landing.utils.ts index cd011a216db76..9315eef4ba0c6 100644 --- a/apps/studio/components/interfaces/Integrations/Landing/Landing.utils.ts +++ b/apps/studio/components/interfaces/Integrations/Landing/Landing.utils.ts @@ -169,7 +169,7 @@ export const isOAuthInstalled = ({ ) } - if (integration.id === 'grafana') { + if (integration.id === 'grafana' || integration.id === 'grafana-cloud') { // Grafana is not yet sending integration status, so just use presence of API key. return ( isOAuthAppAuthorized(projectData, integration) || diff --git a/apps/studio/components/interfaces/Integrations/Landing/MarketplaceDetail.test.tsx b/apps/studio/components/interfaces/Integrations/Landing/MarketplaceDetail.test.tsx index 5ab944ae5b1b2..681f22bd8091a 100644 --- a/apps/studio/components/interfaces/Integrations/Landing/MarketplaceDetail.test.tsx +++ b/apps/studio/components/interfaces/Integrations/Landing/MarketplaceDetail.test.tsx @@ -48,6 +48,21 @@ const STABLE_INTEGRATIONS = [ navigate: () => null, navigation: [{ route: 'overview', label: 'Overview' }], }, + { + id: 'grafana-cloud', + name: 'Grafana', + type: 'oauth', + source: 'Partner', + description: 'Grafana', + content: 'Grafana overview content', + docsUrl: null, + siteUrl: null, + author: { name: 'Grafana Labs' }, + oauthAppId: 'grafana-app', + icon: () => null, + navigate: () => null, + navigation: [{ route: 'overview', label: 'Overview' }], + }, ] const STABLE_EMPTY_ARR = [] as unknown[] vi.mock('@/components/interfaces/Integrations/Landing/useAvailableIntegrations', () => ({ diff --git a/apps/studio/components/interfaces/Integrations/Marketplace/Marketplace.constants.tsx b/apps/studio/components/interfaces/Integrations/Marketplace/Marketplace.constants.tsx index 5c8c8b097bd9b..2d64bb82d65ca 100644 --- a/apps/studio/components/interfaces/Integrations/Marketplace/Marketplace.constants.tsx +++ b/apps/studio/components/interfaces/Integrations/Marketplace/Marketplace.constants.tsx @@ -14,6 +14,7 @@ export type { MarketplaceSource } from '@/components/interfaces/Integrations/Lan // Defines featured integrations and their order in the featured hero export const FEATURED_INTEGRATION_IDS = [ 'grafana', + 'grafana-cloud', 'cron', 'queues', 'stripe_sync_engine', diff --git a/apps/studio/components/interfaces/Integrations/Marketplace/MarketplaceFeaturedHeroGrid.tsx b/apps/studio/components/interfaces/Integrations/Marketplace/MarketplaceFeaturedHeroGrid.tsx index 8a0fe9880feb5..1714b1f6e5268 100644 --- a/apps/studio/components/interfaces/Integrations/Marketplace/MarketplaceFeaturedHeroGrid.tsx +++ b/apps/studio/components/interfaces/Integrations/Marketplace/MarketplaceFeaturedHeroGrid.tsx @@ -25,6 +25,10 @@ const FEATURED_INTEGRATION_IMAGES: Record