mirror of
https://github.com/CopilotKit/CopilotKit.git
synced 2026-09-14 16:26:20 +08:00
436 lines
11 KiB
TypeScript
436 lines
11 KiB
TypeScript
// Sync QA Instructions to Notion
|
|
//
|
|
// Reads qa/*.md files from integration packages and syncs them
|
|
// to Notion pages under the "Showcase QA Instructions" parent page.
|
|
//
|
|
// Structure:
|
|
// Showcase QA Instructions (parent)
|
|
// └── LangGraph (Python) (integration page)
|
|
// ├── Agentic Chat (feature QA page)
|
|
// ├── Human in the Loop (feature QA page)
|
|
// └── ...
|
|
//
|
|
// Usage:
|
|
// NOTION_API_KEY=secret_xxx npx tsx showcase/scripts/sync-qa-to-notion.ts
|
|
//
|
|
// Environment:
|
|
// NOTION_API_KEY - Notion integration token (required)
|
|
// QA_PARENT_PAGE_ID - Notion page ID for "Showcase QA Instructions"
|
|
// (default: 32d3aa38-1852-81af-a1f4-d1ef37402428)
|
|
// DRY_RUN - Set to "true" to preview without writing (optional)
|
|
|
|
import fs from "fs";
|
|
import path from "path";
|
|
import { fileURLToPath } from "url";
|
|
import yaml from "yaml";
|
|
|
|
const __filename = fileURLToPath(import.meta.url);
|
|
const __dirname = path.dirname(__filename);
|
|
|
|
const ROOT = path.resolve(__dirname, "..");
|
|
const PACKAGES_DIR = path.join(ROOT, "packages");
|
|
const FEATURE_REGISTRY_PATH = path.join(
|
|
ROOT,
|
|
"shared",
|
|
"feature-registry.json",
|
|
);
|
|
|
|
const NOTION_API_KEY = process.env.NOTION_API_KEY;
|
|
const QA_PARENT_PAGE_ID =
|
|
process.env.QA_PARENT_PAGE_ID || "3423aa38-1852-8126-84d5-e40a2bd5a7ec";
|
|
const DRY_RUN = process.env.DRY_RUN === "true";
|
|
|
|
const NOTION_API = "https://api.notion.com/v1";
|
|
const NOTION_VERSION = "2022-06-28";
|
|
|
|
interface Feature {
|
|
id: string;
|
|
name: string;
|
|
}
|
|
|
|
interface QAFile {
|
|
integrationSlug: string;
|
|
integrationName: string;
|
|
featureId: string;
|
|
featureName: string;
|
|
content: string;
|
|
filePath: string;
|
|
}
|
|
|
|
// --- Notion API helpers ---
|
|
|
|
async function notionRequest(
|
|
endpoint: string,
|
|
method: string = "GET",
|
|
body?: any,
|
|
): Promise<any> {
|
|
const response = await fetch(`${NOTION_API}${endpoint}`, {
|
|
method,
|
|
headers: {
|
|
Authorization: `Bearer ${NOTION_API_KEY}`,
|
|
"Notion-Version": NOTION_VERSION,
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: body ? JSON.stringify(body) : undefined,
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const error = await response.text();
|
|
throw new Error(`Notion API error ${response.status}: ${error}`);
|
|
}
|
|
|
|
return response.json();
|
|
}
|
|
|
|
async function findChildPage(
|
|
parentId: string,
|
|
title: string,
|
|
): Promise<string | null> {
|
|
const result = await notionRequest(
|
|
`/blocks/${parentId}/children?page_size=100`,
|
|
);
|
|
|
|
for (const block of result.results) {
|
|
if (block.type === "child_page" && block.child_page?.title === title) {
|
|
return block.id;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
async function createPage(
|
|
parentId: string,
|
|
title: string,
|
|
icon?: string,
|
|
): Promise<string> {
|
|
const result = await notionRequest("/pages", "POST", {
|
|
parent: { page_id: parentId },
|
|
properties: {
|
|
title: {
|
|
title: [{ text: { content: title } }],
|
|
},
|
|
},
|
|
...(icon ? { icon: { emoji: icon } } : {}),
|
|
});
|
|
return result.id;
|
|
}
|
|
|
|
function markdownToNotionBlocks(markdown: string): any[] {
|
|
const blocks: any[] = [];
|
|
const lines = markdown.split("\n");
|
|
let i = 0;
|
|
|
|
while (i < lines.length) {
|
|
const line = lines[i];
|
|
|
|
// Headings
|
|
if (line.startsWith("### ")) {
|
|
blocks.push({
|
|
type: "heading_3",
|
|
heading_3: {
|
|
rich_text: [{ text: { content: line.slice(4).trim() } }],
|
|
},
|
|
});
|
|
i++;
|
|
continue;
|
|
}
|
|
if (line.startsWith("## ")) {
|
|
blocks.push({
|
|
type: "heading_2",
|
|
heading_2: {
|
|
rich_text: [{ text: { content: line.slice(3).trim() } }],
|
|
},
|
|
});
|
|
i++;
|
|
continue;
|
|
}
|
|
if (line.startsWith("# ")) {
|
|
blocks.push({
|
|
type: "heading_1",
|
|
heading_1: {
|
|
rich_text: [{ text: { content: line.slice(2).trim() } }],
|
|
},
|
|
});
|
|
i++;
|
|
continue;
|
|
}
|
|
|
|
// Checkbox items (- [ ] or - [x])
|
|
const checkboxMatch = line.match(/^- \[([ x])\] (.+)/);
|
|
if (checkboxMatch) {
|
|
blocks.push({
|
|
type: "to_do",
|
|
to_do: {
|
|
rich_text: [{ text: { content: checkboxMatch[2].trim() } }],
|
|
checked: checkboxMatch[1] === "x",
|
|
},
|
|
});
|
|
i++;
|
|
continue;
|
|
}
|
|
|
|
// Bullet items (- text)
|
|
if (line.startsWith("- ")) {
|
|
blocks.push({
|
|
type: "bulleted_list_item",
|
|
bulleted_list_item: {
|
|
rich_text: [{ text: { content: line.slice(2).trim() } }],
|
|
},
|
|
});
|
|
i++;
|
|
continue;
|
|
}
|
|
|
|
// Horizontal rule
|
|
if (line.match(/^---+$/)) {
|
|
blocks.push({ type: "divider", divider: {} });
|
|
i++;
|
|
continue;
|
|
}
|
|
|
|
// Empty line
|
|
if (line.trim() === "") {
|
|
i++;
|
|
continue;
|
|
}
|
|
|
|
// Regular paragraph
|
|
blocks.push({
|
|
type: "paragraph",
|
|
paragraph: {
|
|
rich_text: [{ text: { content: line } }],
|
|
},
|
|
});
|
|
i++;
|
|
}
|
|
|
|
return blocks;
|
|
}
|
|
|
|
async function replacePageContent(
|
|
pageId: string,
|
|
blocks: any[],
|
|
): Promise<void> {
|
|
// First, delete all existing blocks
|
|
const existing = await notionRequest(
|
|
`/blocks/${pageId}/children?page_size=100`,
|
|
);
|
|
for (const block of existing.results) {
|
|
await notionRequest(`/blocks/${block.id}`, "DELETE");
|
|
}
|
|
|
|
// Then add new blocks in batches of 100 (Notion API limit)
|
|
for (let i = 0; i < blocks.length; i += 100) {
|
|
const batch = blocks.slice(i, i + 100);
|
|
await notionRequest(`/blocks/${pageId}/children`, "PATCH", {
|
|
children: batch,
|
|
});
|
|
}
|
|
}
|
|
|
|
// --- Main logic ---
|
|
|
|
function loadFeatureNames(): Map<string, string> {
|
|
const raw = fs.readFileSync(FEATURE_REGISTRY_PATH, "utf-8");
|
|
const registry = JSON.parse(raw);
|
|
const map = new Map<string, string>();
|
|
for (const feature of registry.features) {
|
|
map.set(feature.id, feature.name);
|
|
}
|
|
return map;
|
|
}
|
|
|
|
function collectQAFiles(): QAFile[] {
|
|
const featureNames = loadFeatureNames();
|
|
const qaFiles: QAFile[] = [];
|
|
|
|
if (!fs.existsSync(PACKAGES_DIR)) return qaFiles;
|
|
|
|
const packageDirs = fs
|
|
.readdirSync(PACKAGES_DIR, { withFileTypes: true })
|
|
.filter((d) => d.isDirectory())
|
|
.map((d) => d.name);
|
|
|
|
for (const pkgDir of packageDirs) {
|
|
const manifestPath = path.join(PACKAGES_DIR, pkgDir, "manifest.yaml");
|
|
if (!fs.existsSync(manifestPath)) continue;
|
|
|
|
const manifest = yaml.parse(fs.readFileSync(manifestPath, "utf-8"));
|
|
const qaDir = path.join(PACKAGES_DIR, pkgDir, "qa");
|
|
if (!fs.existsSync(qaDir)) continue;
|
|
|
|
const qaEntries = fs.readdirSync(qaDir).filter((f) => f.endsWith(".md"));
|
|
|
|
for (const qaFile of qaEntries) {
|
|
const featureId = qaFile.replace(".md", "");
|
|
const content = fs.readFileSync(path.join(qaDir, qaFile), "utf-8");
|
|
|
|
qaFiles.push({
|
|
integrationSlug: manifest.slug,
|
|
integrationName: manifest.name,
|
|
featureId,
|
|
featureName: featureNames.get(featureId) || featureId,
|
|
content,
|
|
filePath: path.join(qaDir, qaFile),
|
|
});
|
|
}
|
|
}
|
|
|
|
return qaFiles;
|
|
}
|
|
|
|
async function syncToNotion(qaFiles: QAFile[]): Promise<void> {
|
|
// Group by integration
|
|
const byIntegration = new Map<string, { name: string; files: QAFile[] }>();
|
|
for (const file of qaFiles) {
|
|
if (!byIntegration.has(file.integrationSlug)) {
|
|
byIntegration.set(file.integrationSlug, {
|
|
name: file.integrationName,
|
|
files: [],
|
|
});
|
|
}
|
|
byIntegration.get(file.integrationSlug)!.files.push(file);
|
|
}
|
|
|
|
console.log(
|
|
`Syncing ${qaFiles.length} QA docs across ${byIntegration.size} integrations\n`,
|
|
);
|
|
|
|
for (const [slug, { name, files }] of byIntegration) {
|
|
console.log(` ${name} (${slug}): ${files.length} QA docs`);
|
|
|
|
if (DRY_RUN) {
|
|
for (const file of files) {
|
|
console.log(` - ${file.featureName} (${file.featureId})`);
|
|
}
|
|
continue;
|
|
}
|
|
|
|
// Find or create integration page
|
|
let integrationPageId = await findChildPage(QA_PARENT_PAGE_ID, name);
|
|
if (!integrationPageId) {
|
|
console.log(` Creating integration page: ${name}`);
|
|
integrationPageId = await createPage(QA_PARENT_PAGE_ID, name, "📦");
|
|
}
|
|
|
|
// Sync each feature QA doc
|
|
for (const file of files) {
|
|
const pageTitle = `QA: ${file.featureName}`;
|
|
let featurePageId = await findChildPage(integrationPageId, pageTitle);
|
|
|
|
if (!featurePageId) {
|
|
console.log(` Creating: ${pageTitle}`);
|
|
featurePageId = await createPage(integrationPageId, pageTitle, "✅");
|
|
} else {
|
|
console.log(` Updating: ${pageTitle}`);
|
|
}
|
|
|
|
// Convert markdown to Notion blocks and replace content
|
|
const blocks = markdownToNotionBlocks(file.content);
|
|
|
|
// Add source file reference at the bottom
|
|
blocks.push(
|
|
{ type: "divider", divider: {} },
|
|
{
|
|
type: "paragraph",
|
|
paragraph: {
|
|
rich_text: [
|
|
{
|
|
text: { content: "Source: " },
|
|
annotations: { italic: true, color: "gray" },
|
|
},
|
|
{
|
|
text: {
|
|
content: `showcase/packages/${file.integrationSlug}/qa/${file.featureId}.md`,
|
|
link: {
|
|
url: `https://github.com/CopilotKit/CopilotKit/blob/main/showcase/packages/${file.integrationSlug}/qa/${file.featureId}.md`,
|
|
},
|
|
},
|
|
annotations: { italic: true, color: "gray", code: true },
|
|
},
|
|
],
|
|
},
|
|
},
|
|
{
|
|
type: "paragraph",
|
|
paragraph: {
|
|
rich_text: [
|
|
{
|
|
text: {
|
|
content: `Last synced: ${new Date().toISOString()}`,
|
|
},
|
|
annotations: { italic: true, color: "gray" },
|
|
},
|
|
],
|
|
},
|
|
},
|
|
);
|
|
|
|
await replacePageContent(featurePageId, blocks);
|
|
}
|
|
}
|
|
|
|
// Update the parent page's "last synced" timestamp
|
|
if (!DRY_RUN) {
|
|
const parentBlocks = await notionRequest(
|
|
`/blocks/${QA_PARENT_PAGE_ID}/children?page_size=100`,
|
|
);
|
|
for (const block of parentBlocks.results) {
|
|
if (
|
|
block.type === "paragraph" &&
|
|
block.paragraph?.rich_text?.[0]?.text?.content?.startsWith(
|
|
"Last synced:",
|
|
)
|
|
) {
|
|
await notionRequest(`/blocks/${block.id}`, "PATCH", {
|
|
paragraph: {
|
|
rich_text: [
|
|
{
|
|
text: {
|
|
content: `Last synced: ${new Date().toISOString()}`,
|
|
},
|
|
annotations: { italic: true },
|
|
},
|
|
],
|
|
},
|
|
});
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
async function main() {
|
|
if (!NOTION_API_KEY) {
|
|
console.error("Error: NOTION_API_KEY environment variable is required.");
|
|
console.error(
|
|
"Create a Notion integration at https://www.notion.so/my-integrations",
|
|
);
|
|
console.error("and share the QA parent page with it.");
|
|
process.exit(1);
|
|
}
|
|
|
|
if (DRY_RUN) {
|
|
console.log("DRY RUN mode — no changes will be made to Notion\n");
|
|
}
|
|
|
|
const qaFiles = collectQAFiles();
|
|
|
|
if (qaFiles.length === 0) {
|
|
console.log("No QA files found in any integration packages.");
|
|
return;
|
|
}
|
|
|
|
console.log(`Found ${qaFiles.length} QA files:\n`);
|
|
|
|
try {
|
|
await syncToNotion(qaFiles);
|
|
console.log("\nSync complete.");
|
|
} catch (error) {
|
|
console.error("\nSync failed:", error);
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
main();
|