Files
supabase/apps/docs/features/docs/Troubleshooting.script.mjs
Charis 65b8ef91ac fix(troubleshooting sync) (#40096)
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
2025-11-04 08:16:46 +01:00

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()