mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 17:35:10 +03:00
A few troubleshooting guides aren't syncing properly, for miscellaneous issues: - Misformatted link - Edge cases with Markdown parsing: - GFM automatically wraps emails in angle brackets, which subsequently breaks MDX because it expects anything wrapped in angle brackets to be a JSX tag - toMarkdown inserts sentinel comments between certain blocks, which subsequently breaks MDX for the same reason
503 lines
13 KiB
JavaScript
503 lines
13 KiB
JavaScript
// @ts-check
|
|
|
|
/* eslint-disable turbo/no-undeclared-env-vars */
|
|
|
|
/**
|
|
* Sync new troubleshooting entries from the GitHub repo with GitHub
|
|
* Discussions.
|
|
*/
|
|
|
|
import '../../scripts/utils/dotenv.js'
|
|
|
|
import { createAppAuth } from '@octokit/auth-app'
|
|
import { Octokit } from '@octokit/core'
|
|
import { createClient } from '@supabase/supabase-js'
|
|
import { createHash } from 'crypto'
|
|
import matter from 'gray-matter'
|
|
import { fromMarkdown } from 'mdast-util-from-markdown'
|
|
import { gfmFromMarkdown, gfmToMarkdown } from 'mdast-util-gfm'
|
|
import { mdxFromMarkdown, mdxToMarkdown } from 'mdast-util-mdx'
|
|
import { toMarkdown } from 'mdast-util-to-markdown'
|
|
import { gfm } from 'micromark-extension-gfm'
|
|
import { mdxjs } from 'micromark-extension-mdxjs'
|
|
import { readFile, writeFile } from 'node:fs/promises'
|
|
import { parse, stringify } from 'smol-toml'
|
|
|
|
import {
|
|
getAllTroubleshootingEntriesInternal as getAllTroubleshootingEntries,
|
|
getArticleSlug,
|
|
} from './Troubleshooting.utils.common.mjs'
|
|
|
|
const REPOSITORY_ID = 'MDEwOlJlcG9zaXRvcnkyMTQ1ODcxOTM='
|
|
const TROUBLESHOOTING_CATEGORY_ID = 'DIC_kwDODMpXOc4CUvEr'
|
|
const REPOSITORY_OWNER = 'supabase'
|
|
const REPOSITORY_NAME = 'supabase'
|
|
|
|
/**
|
|
* @typedef {import('./Troubleshooting.utils.common.mjs').TroubleshootingEntry} TroubleshootingEntry
|
|
* @typedef {import('./Troubleshooting.utils.common.mjs').TroubleshootingMetadata} TroubleshootingMetadata
|
|
*/
|
|
|
|
/**
|
|
* @type {import('@octokit/core').Octokit}
|
|
*/
|
|
let octokitInstance
|
|
/**
|
|
* @type {import('@supabase/supabase-js').SupabaseClient<import('../../../../packages/common').Database>}
|
|
*/
|
|
let supabaseAdminClient
|
|
|
|
function octokit() {
|
|
if (!octokitInstance) {
|
|
octokitInstance = new Octokit({
|
|
authStrategy: createAppAuth,
|
|
auth: {
|
|
appId: process.env.DOCS_GITHUB_APP_ID,
|
|
installationId: process.env.DOCS_GITHUB_APP_INSTALLATION_ID,
|
|
privateKey: process.env.DOCS_GITHUB_APP_PRIVATE_KEY,
|
|
},
|
|
})
|
|
}
|
|
|
|
return octokitInstance
|
|
}
|
|
|
|
export function supabaseAdmin() {
|
|
if (!supabaseAdminClient) {
|
|
supabaseAdminClient = createClient(
|
|
/** @type {string} */ (process.env.NEXT_PUBLIC_SUPABASE_URL),
|
|
/** @type {string} */ (process.env.SUPABASE_SECRET_KEY)
|
|
)
|
|
}
|
|
|
|
return supabaseAdminClient
|
|
}
|
|
|
|
async function syncTroubleshootingEntries() {
|
|
console.log('[START] Syncing troubleshooting entries to GitHub')
|
|
|
|
const [troubleshootingEntries, discussions] = await Promise.all([
|
|
getAllTroubleshootingEntries(),
|
|
getAllTroubleshootingDiscussions(),
|
|
])
|
|
|
|
console.log(`[INFO] Found ${troubleshootingEntries.length} existing entries`)
|
|
|
|
const tasks = troubleshootingEntries.map(async (entry) => {
|
|
const databaseId = entry.data.database_id
|
|
if (databaseId.startsWith('pseudo-')) {
|
|
// The database entry is faked, so we may need to create a new one.
|
|
// There's also an edge case we need to check for: the entry has already
|
|
// been created, but the new database ID hasn't been written to the file
|
|
// yet.
|
|
if (await entryExists(entry)) return
|
|
|
|
const discussion = entry.data.github_url
|
|
? await getGithubIdForDiscussion(discussions, entry)
|
|
: await createGithubDiscussion(entry)
|
|
|
|
let id
|
|
try {
|
|
id = await insertNewTroubleshootingEntry(entry, discussion)
|
|
} catch (error) {
|
|
console.error(`[ERROR] Failed to insert new entry for ${entry.data.title}: %O`, error)
|
|
console.log(
|
|
`[INFO] Rolling back discussion creation for ${entry.data.title} (GitHub ID ${discussion.id})`
|
|
)
|
|
await rollbackGithubDiscussion(discussion.id)
|
|
throw error
|
|
}
|
|
|
|
await updateFileId(entry, id)
|
|
} else {
|
|
// The database entry already exists, so check for updates.
|
|
const contentHasChanged = await updateChecksumIfNeeded(entry)
|
|
if (contentHasChanged) {
|
|
await updateGithubDiscussion(entry)
|
|
}
|
|
}
|
|
})
|
|
|
|
const results = await Promise.allSettled(tasks)
|
|
let hasErrors = false
|
|
results.forEach((result, index) => {
|
|
if (result.status === 'rejected') {
|
|
console.error(
|
|
`[ERROR] Failed to insert and/or update for ${troubleshootingEntries[index].filePath}:\n%O`,
|
|
result.reason?.errors ?? result.reason
|
|
)
|
|
hasErrors = true
|
|
}
|
|
})
|
|
|
|
return hasErrors
|
|
}
|
|
|
|
/**
|
|
* @param {TroubleshootingEntry} entry
|
|
* @throws If the database check fails for some unknown reason
|
|
*/
|
|
async function entryExists(entry) {
|
|
const checksum = calculateChecksum(entry.content)
|
|
const { error } = await supabaseAdmin()
|
|
.from('troubleshooting_entries')
|
|
.select('id')
|
|
.eq('checksum', checksum)
|
|
.single()
|
|
|
|
if (error) {
|
|
if (error.code === 'PGRST116') {
|
|
// No entry found
|
|
return false
|
|
}
|
|
throw error
|
|
}
|
|
|
|
console.log(
|
|
`[INFO] Entry for ${entry.data.title} already exists. Not creating a new one to prevent duplicates.`
|
|
)
|
|
return true
|
|
}
|
|
|
|
/**
|
|
* @param {string} content
|
|
*/
|
|
function calculateChecksum(content) {
|
|
// Normalize to ignore changes that don't affect the final displayed content.
|
|
const mdast = fromMarkdown(content, {
|
|
extensions: [gfm(), mdxjs()],
|
|
mdastExtensions: [gfmFromMarkdown(), mdxFromMarkdown()],
|
|
})
|
|
const bodyNormalized = toMarkdown(mdast, { extensions: [gfmToMarkdown(), mdxToMarkdown()] })
|
|
|
|
const { data, content: body } = matter(bodyNormalized, {
|
|
language: 'toml',
|
|
engines: { toml: parse },
|
|
})
|
|
const newFrontmatter = stringify(data)
|
|
const normalized = `---\n${newFrontmatter}\n---\n${body}`
|
|
|
|
return createHash('sha256').update(normalized).digest('base64')
|
|
}
|
|
|
|
/**
|
|
* @param {TroubleshootingEntry} entry
|
|
*
|
|
* @param {Object} discussion
|
|
* @param {string} discussion.id
|
|
* @param {string} discussion.url
|
|
*
|
|
* @throws If the database insertion fails
|
|
*/
|
|
async function insertNewTroubleshootingEntry(entry, discussion) {
|
|
console.log(`[INFO] Inserting entry for ${entry.data.title} into DB`)
|
|
|
|
const now = new Date().toISOString()
|
|
const checksum = calculateChecksum(entry.content)
|
|
|
|
const { data, error } = await supabaseAdmin()
|
|
.from('troubleshooting_entries')
|
|
.insert({
|
|
api: entry.data.api,
|
|
checksum,
|
|
date_created: entry.data.date_created?.toISOString() ?? now,
|
|
date_updated: now,
|
|
errors: entry.data.errors,
|
|
github_id: discussion.id,
|
|
github_url: discussion.url,
|
|
keywords: entry.data.keywords,
|
|
title: entry.data.title,
|
|
topics: entry.data.topics,
|
|
})
|
|
.select('id')
|
|
.single()
|
|
if (error) {
|
|
throw error
|
|
}
|
|
|
|
return data.id
|
|
}
|
|
|
|
/**
|
|
* @param {TroubleshootingEntry} entry
|
|
*
|
|
* @throws If the database update fails
|
|
*/
|
|
async function updateChecksumIfNeeded(entry) {
|
|
const { data, error } = await supabaseAdmin()
|
|
.from('troubleshooting_entries')
|
|
.select('checksum')
|
|
.eq('id', entry.data.database_id)
|
|
.single()
|
|
if (error) {
|
|
throw error
|
|
}
|
|
|
|
const newChecksum = calculateChecksum(entry.content)
|
|
if (data.checksum !== newChecksum) {
|
|
console.log(`[INFO] Content changed for ${entry.data.title}. Updating checksum.`)
|
|
|
|
const now = new Date().toISOString()
|
|
const { error } = await supabaseAdmin()
|
|
.from('troubleshooting_entries')
|
|
.update({
|
|
checksum: newChecksum,
|
|
date_updated: now,
|
|
})
|
|
.eq('id', entry.data.database_id)
|
|
|
|
if (error) {
|
|
throw error
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
/**
|
|
* Converts relative links to absolute URLs for GitHub discussions using MDAST
|
|
* @param {string} content - The markdown content to process (already stripped of JSX)
|
|
*/
|
|
function rewriteRelativeLinks(content) {
|
|
const baseUrl = 'https://supabase.com'
|
|
|
|
// Parse the markdown to AST
|
|
const mdast = fromMarkdown(content, {
|
|
extensions: [gfm()],
|
|
mdastExtensions: [gfmFromMarkdown()],
|
|
})
|
|
|
|
// Walk the tree and modify link nodes
|
|
/**
|
|
* @param {import('mdast').Root|import('mdast').Content} node
|
|
*/
|
|
function visitNode(node) {
|
|
if (node.type === 'link' && node.url && node.url.startsWith('/')) {
|
|
// Convert relative URL to absolute
|
|
node.url = `${baseUrl}${node.url}`
|
|
}
|
|
|
|
// Recursively visit children
|
|
if ('children' in node) {
|
|
node.children.forEach(visitNode)
|
|
}
|
|
}
|
|
|
|
visitNode(mdast)
|
|
|
|
// Convert back to markdown
|
|
return toMarkdown(mdast, { extensions: [gfmToMarkdown()] })
|
|
}
|
|
|
|
/**
|
|
* @param {TroubleshootingEntry} entry
|
|
*/
|
|
function addCanonicalUrl(entry) {
|
|
const docsUrl = 'https://supabase.com/docs/guides/troubleshooting/' + getArticleSlug(entry)
|
|
const contentWithAbsoluteLinks = rewriteRelativeLinks(entry.contentWithoutJsx)
|
|
const content =
|
|
`_This is a copy of a troubleshooting article on Supabase's docs site. It may be missing some details from the original. View the [original article](${docsUrl})._\n\n` +
|
|
contentWithAbsoluteLinks
|
|
return content
|
|
}
|
|
|
|
/**
|
|
* @param {TroubleshootingEntry} entry
|
|
*/
|
|
async function createGithubDiscussion(entry) {
|
|
console.log(`[INFO] Creating GitHub discussion for ${entry.data.title}`)
|
|
const content = addCanonicalUrl(entry)
|
|
|
|
const mutation = `
|
|
mutation CreateDiscussionMutation(
|
|
$repository: ID!,
|
|
$category: ID!,
|
|
$title: String!,
|
|
$body: String!
|
|
) {
|
|
createDiscussion(input: {
|
|
repositoryId: $repository,
|
|
categoryId: $category,
|
|
body: $body,
|
|
title: $title
|
|
}) {
|
|
discussion {
|
|
id
|
|
url
|
|
}
|
|
}
|
|
}
|
|
`
|
|
|
|
const {
|
|
createDiscussion: { discussion },
|
|
} = await octokit().graphql(mutation, {
|
|
repository: REPOSITORY_ID,
|
|
category: TROUBLESHOOTING_CATEGORY_ID,
|
|
body: content,
|
|
title: entry.data.title,
|
|
})
|
|
console.log(`[INFO] Created GitHub discussion for ${entry.data.title}: %s`, discussion.url)
|
|
return discussion
|
|
}
|
|
|
|
/**
|
|
* @returns {Promise<{id: string, url: string}[]>}
|
|
*/
|
|
async function getAllTroubleshootingDiscussions() {
|
|
const query = `
|
|
query getDiscussions($cursor: String) {
|
|
repository(owner: "${REPOSITORY_OWNER}", name: "${REPOSITORY_NAME}") {
|
|
discussions(first: 100, after: $cursor, categoryId: "${TROUBLESHOOTING_CATEGORY_ID}") {
|
|
pageInfo {
|
|
hasNextPage
|
|
endCursor
|
|
}
|
|
nodes {
|
|
id
|
|
url
|
|
}
|
|
}
|
|
}
|
|
}
|
|
`
|
|
|
|
const discussions = []
|
|
let hasNextPage = true
|
|
let cursor
|
|
|
|
while (hasNextPage) {
|
|
const {
|
|
repository: {
|
|
discussions: { nodes: moreDiscussions, pageInfo },
|
|
},
|
|
} = await octokit().graphql(query, { cursor })
|
|
|
|
discussions.push(...moreDiscussions)
|
|
hasNextPage = pageInfo.hasNextPage
|
|
cursor = pageInfo.endCursor
|
|
}
|
|
|
|
return discussions
|
|
}
|
|
|
|
/**
|
|
* @param {{id: string, url: string}[]} discussions
|
|
* @param {TroubleshootingEntry} entry
|
|
*
|
|
* @throws If matching discussion not found
|
|
*/
|
|
async function getGithubIdForDiscussion(discussions, entry) {
|
|
const matchingDiscussion = discussions.find(
|
|
(discussion) => discussion.url === entry.data.github_url
|
|
)
|
|
if (!matchingDiscussion) {
|
|
throw new Error(`No matching discussion found for URL: ${entry.data.github_url}`)
|
|
}
|
|
return matchingDiscussion
|
|
}
|
|
|
|
/**
|
|
* @param {TroubleshootingEntry} entry
|
|
*
|
|
* @throws If stored discussion ID for entry not found
|
|
*/
|
|
async function updateGithubDiscussion(entry) {
|
|
console.log(`[INFO] Updating discussion content for ${entry.data.title}`)
|
|
|
|
const { data, error } = await supabaseAdmin()
|
|
.from('troubleshooting_entries')
|
|
.select('github_id')
|
|
.eq('id', entry.data.database_id)
|
|
.single()
|
|
if (error) {
|
|
throw error
|
|
}
|
|
|
|
const content = addCanonicalUrl(entry)
|
|
const mutation = `
|
|
mutation UpdateDiscussionMutation(
|
|
$discussionId: ID!,
|
|
$body: String!
|
|
) {
|
|
updateDiscussion(input: {
|
|
discussionId: $discussionId,
|
|
body: $body
|
|
}) {
|
|
discussion {
|
|
id
|
|
}
|
|
}
|
|
}
|
|
`
|
|
try {
|
|
await octokit().graphql(mutation, { discussionId: data.github_id, body: content })
|
|
console.log(`[INFO] Updated discussion content for ${entry.data.title}`)
|
|
} catch (err) {
|
|
console.error('[DEBUG] Failed GraphQL mutation:\n', mutation)
|
|
throw err
|
|
}
|
|
}
|
|
|
|
/** @param {string} id */
|
|
async function rollbackGithubDiscussion(id) {
|
|
try {
|
|
const mutation = `
|
|
mutation DeleteDiscussionMutation(
|
|
$discussionId: ID!
|
|
) {
|
|
deleteDiscussion(input: {
|
|
discussionId: $discussionId,
|
|
}) {
|
|
discussion {
|
|
id
|
|
}
|
|
}
|
|
}
|
|
`
|
|
|
|
await octokit().graphql(mutation, { discussionId: id })
|
|
console.log(`[INFO] Rolled back discussion creation for ${id}`)
|
|
} catch (error) {
|
|
console.error(`[ERROR] Failed to rollback discussion creation for ${id}: %O`, error)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {TroubleshootingEntry} entry
|
|
* @param {string} id
|
|
*
|
|
* @throws Passes through readFile and writeFile errors without catching
|
|
*/
|
|
async function updateFileId(entry, id) {
|
|
console.log(`[INFO] Writing database ID to file for ${entry.filePath}`)
|
|
|
|
const fileContents = await readFile(entry.filePath, 'utf-8')
|
|
const { data, content } = matter(fileContents, {
|
|
language: 'toml',
|
|
engines: { toml: parse },
|
|
})
|
|
data.database_id = id
|
|
|
|
const newFrontmatter = stringify(data)
|
|
const newContent = `---\n${newFrontmatter}\n---\n${content}`
|
|
|
|
await writeFile(entry.filePath, newContent)
|
|
}
|
|
|
|
async function main() {
|
|
try {
|
|
const hasErrors = await syncTroubleshootingEntries()
|
|
if (hasErrors) {
|
|
process.exit(1)
|
|
}
|
|
} catch (error) {
|
|
console.error(`[ERROR] %O`, error)
|
|
process.exit(1)
|
|
}
|
|
}
|
|
|
|
main()
|