diff --git a/.github/workflows/llm-doc-sync.yaml b/.github/workflows/llm-doc-sync.yaml new file mode 100644 index 0000000..14e4c0b --- /dev/null +++ b/.github/workflows/llm-doc-sync.yaml @@ -0,0 +1,83 @@ +name: LLM doc sync + +on: + repository_dispatch: + types: [source-repo-updated] + workflow_dispatch: + inputs: + repo: + description: 'Source repo name (e.g. helix-commerce-api)' + required: true + ref: + description: 'Commit SHA to sync to' + required: true + version: + description: 'Version string (e.g. v2.43.0)' + required: false + default: '' + +# Only one sync per source repo at a time. +concurrency: + group: llm-doc-sync-${{ github.event.inputs.repo || github.event.client_payload.repo }} + cancel-in-progress: true + +jobs: + sync: + runs-on: ubuntu-latest + + env: + # Normalize dispatch vs manual inputs into a single set of env vars. + SOURCE_REPO: ${{ github.event.inputs.repo || github.event.client_payload.repo }} + SOURCE_REF: ${{ github.event.inputs.ref || github.event.client_payload.ref }} + SOURCE_VERSION: ${{ github.event.inputs.version || github.event.client_payload.version || '' }} + + steps: + - name: Create app token + id: app-token + uses: actions/create-github-app-token@v1 + with: + app-id: ${{ secrets.HELIX_REVIEW_BOT_APP_ID }} + private-key: ${{ secrets.HELIX_REVIEW_BOT_PRIVATE_KEY }} + owner: adobe-rnd + + - name: Checkout docs repo + uses: actions/checkout@v4 + + - name: Checkout source repo + uses: actions/checkout@v4 + with: + repository: adobe-rnd/${{ env.SOURCE_REPO }} + token: ${{ steps.app-token.outputs.token }} + path: .source-repo + fetch-depth: 0 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 24 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Run LLM doc sync + run: node scripts/docs/llm-sync.mjs + env: + SOURCE_REPO_PATH: ${{ github.workspace }}/.source-repo + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + + - name: Create pull request + uses: peter-evans/create-pull-request@v6 + with: + branch: auto/docs-update-${{ env.SOURCE_REPO }} + delete-branch: true + title: 'docs: update for ${{ env.SOURCE_REPO }}@${{ env.SOURCE_REF }}' + body: | + Automated doc update triggered by changes in `${{ env.SOURCE_REPO }}`. + + - Source ref: `${{ env.SOURCE_REF }}` + - Version: `${{ env.SOURCE_VERSION }}` + + Review the changes carefully — this PR was drafted by an LLM. + labels: ai-authored + commit-message: 'docs: sync with ${{ env.SOURCE_REPO }}@${{ env.SOURCE_REF }}' diff --git a/scripts/docs/README.md b/scripts/docs/README.md index ef9aba8..2770ef0 100644 --- a/scripts/docs/README.md +++ b/scripts/docs/README.md @@ -95,6 +95,36 @@ npm run docs:da:push -- --no-preview Overrides: `DA_ORG`, `DA_SITE` (defaults `aemsites` / `edge-commerce-docs`), `HELIX_API_HOST` (default `https://api.aem.live`). +## LLM doc sync + +`llm-sync.mjs` automatically updates narrative docs when a source repo changes. It reads each `docs/*.md` file, checks its frontmatter `sources` block for the target repo, diffs the source repo since the last reviewed commit, sends the diff and current doc to an LLM, and writes the updated doc back. + +The workflow (`.github/workflows/llm-doc-sync.yaml`) runs on `repository_dispatch` events or manual `workflow_dispatch` triggers. It checks out both this repo and the source repo, runs the sync script, and opens a PR with the label `ai-authored`. + +### Run locally + +```bash +export SOURCE_REPO="helix-commerce-api" +export SOURCE_REF="abc1234" # commit SHA to sync to +export SOURCE_VERSION="v2.43.0" # optional version string +export SOURCE_REPO_PATH="/path/to/source/repo" # full clone with history +export OPENAI_API_KEY="sk-..." + +node scripts/docs/llm-sync.mjs +``` + +### Environment variables + +| Variable | Required | Description | +|---|---|---| +| `SOURCE_REPO` | yes | Source repo name (e.g. `helix-commerce-api`) | +| `SOURCE_REF` | yes | Commit SHA to sync to | +| `SOURCE_VERSION` | no | Version string (e.g. `v2.43.0`) | +| `SOURCE_REPO_PATH` | yes | Absolute path to a full clone of the source repo | +| `OPENAI_API_KEY` | yes | OpenAI API key | + +The script filters out test files, CI config, and lock files from the diff so only meaningful code changes drive doc updates. If no docs need content changes, it exits cleanly. + ## Path mapping Each Markdown file uses its frontmatter `daPath` when present. For example: diff --git a/scripts/docs/llm-sync.mjs b/scripts/docs/llm-sync.mjs new file mode 100644 index 0000000..3bc0532 --- /dev/null +++ b/scripts/docs/llm-sync.mjs @@ -0,0 +1,314 @@ +/* eslint-disable no-console */ +/* + * LLM-driven narrative doc sync + * + * Reads docs/*.md files, finds those tracking a given source repo in their + * frontmatter `sources` block, diffs the source repo since the last reviewed + * commit, sends the diff + current doc + style guide to an LLM, and writes + * the updated doc back to disk. + * + * Environment variables: + * SOURCE_REPO — repo name (e.g. "helix-commerce-api") + * SOURCE_REF — commit SHA to sync to + * SOURCE_VERSION — version string (e.g. "v2.43.0") + * SOURCE_REPO_PATH — absolute path to a full clone of the source repo + * OPENAI_API_KEY — OpenAI API key + */ + +import { readdir, readFile, writeFile } from 'node:fs/promises'; +import { execSync } from 'node:child_process'; +import { join, resolve } from 'node:path'; + +// --------------------------------------------------------------------------- +// Config +// --------------------------------------------------------------------------- + +const OPENAI_MODEL = 'gpt-5.6-terra'; +const OPENAI_ENDPOINT = 'https://api.openai.com/v1/chat/completions'; +const MAX_TOKENS = 16000; +const TEMPERATURE = 0.3; + +const DOCS_DIR = resolve('docs'); +const STYLE_GUIDE_PATH = resolve('CLAUDE.md'); + +// Paths / patterns excluded from the source-repo diff. +const DIFF_EXCLUDE = [ + 'test/', + 'tests/', + '__tests__/', + '*.test.js', + '*.test.mjs', + '*.spec.js', + '*.spec.mjs', + 'package-lock.json', + '.github/workflows/', +]; + +// Files whose changes alone don't warrant a doc update. +const TRIVIAL_ONLY_PATTERNS = [ + /^package-lock\.json$/, + /^\.github\//, + /^\.eslint/, + /^\.stylelint/, + /^\.editorconfig$/, + /^\.gitignore$/, + /^\.npmrc$/, + /^renovate\.json/, +]; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** + * Parse YAML-ish frontmatter from a markdown string. + * Returns { attrs: {…}, body: string, raw: string }. + * + * Handles arbitrary nesting depth by tracking indent level. Each 2-space + * indent opens a new object level. Enough for the frontmatter we use + * (up to 3 levels deep: sources → repo-name → version/commit). + */ +function parseFrontmatter(content) { + const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/); + if (!match) return { attrs: {}, body: content, raw: '' }; + + const raw = match[1]; + const body = content.slice(match[0].length).replace(/^\r?\n/, ''); + const root = {}; + + // Stack of { obj, key } frames tracking the nesting context at each + // indent level. stack[0] is always { obj: root }. + const stack = [{ obj: root }]; + + for (const line of raw.split('\n')) { + if (!line.trim()) continue; // skip blank lines + + const m = line.match(/^( *)(\w[\w-]*):\s*(.*?)\s*$/); + if (!m) continue; + + const indent = m[1].length / 2; // 0, 1, 2, … + const key = m[2]; + const valRaw = m[3].replace(/^"(.*)"$/, '$1'); + + // Pop stack back to the correct depth. + while (stack.length > indent + 1) stack.pop(); + + const parent = stack[stack.length - 1].obj; + + if (valRaw === '') { + // Key with no value → opens a nested object. + const child = {}; + parent[key] = child; + stack.push({ obj: child, key }); + } else { + parent[key] = valRaw; + } + } + + return { attrs: root, body, raw }; +} + +/** + * Serialize frontmatter attrs back into a YAML block + body. + */ +function serializeFrontmatter(attrs, body) { + const lines = ['---']; + + for (const [key, value] of Object.entries(attrs)) { + if (typeof value === 'object' && value !== null) { + lines.push(`${key}:`); + for (const [k, v] of Object.entries(value)) { + if (typeof v === 'object' && v !== null) { + lines.push(` ${k}:`); + for (const [kk, vv] of Object.entries(v)) { + lines.push(` ${kk}: "${vv}"`); + } + } else { + lines.push(` ${k}: "${v}"`); + } + } + } else { + const needsQuotes = typeof value === 'string' && value.length > 0; + lines.push(`${key}: ${needsQuotes ? `"${value}"` : value}`); + } + } + + lines.push('---'); + return `${lines.join('\n')}\n${body}`; +} + +/** + * Compute a filtered diff between two refs in the source repo. + */ +function getFilteredDiff(repoPath, fromRef, toRef) { + const excludeArgs = DIFF_EXCLUDE + .map((p) => `':(exclude)${p}'`) + .join(' '); + + try { + const cmd = `git diff ${fromRef}..${toRef} -- . ${excludeArgs}`; + return execSync(cmd, { + cwd: repoPath, + encoding: 'utf-8', + maxBuffer: 10 * 1024 * 1024, // 10 MB + }); + } catch { + return ''; + } +} + +/** + * Check if a diff contains only trivial file changes (deps, CI, config). + */ +function isTrivialDiff(diff) { + if (!diff || !diff.trim()) return true; + + // Extract changed file paths from the diff header lines. + const files = [...diff.matchAll(/^diff --git a\/(.+?) b\//gm)] + .map((m) => m[1]); + + return files.every((f) => TRIVIAL_ONLY_PATTERNS.some((p) => p.test(f))); +} + +/** + * Call the OpenAI chat completions API. + */ +async function callLLM(systemPrompt, userPrompt, apiKey) { + const res = await fetch(OPENAI_ENDPOINT, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${apiKey}`, + }, + body: JSON.stringify({ + model: OPENAI_MODEL, + max_tokens: MAX_TOKENS, + temperature: TEMPERATURE, + messages: [ + { role: 'system', content: systemPrompt }, + { role: 'user', content: userPrompt }, + ], + }), + }); + + if (!res.ok) { + const text = await res.text(); + throw new Error(`OpenAI API error ${res.status}: ${text}`); + } + + const data = await res.json(); + return data.choices[0].message.content; +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +async function main() { + const sourceRepo = process.env.SOURCE_REPO; + const sourceRef = process.env.SOURCE_REF; + const sourceVersion = process.env.SOURCE_VERSION || ''; + const sourceRepoPath = process.env.SOURCE_REPO_PATH; + const apiKey = process.env.OPENAI_API_KEY; + + if (!sourceRepo || !sourceRef || !sourceRepoPath || !apiKey) { + console.error( + 'Required env vars: SOURCE_REPO, SOURCE_REF, SOURCE_REPO_PATH, OPENAI_API_KEY', + ); + process.exit(1); + } + + // Read the style guide once. + const styleGuide = await readFile(STYLE_GUIDE_PATH, 'utf-8'); + + // Scan docs/*.md + const entries = await readdir(DOCS_DIR); + const mdFiles = entries.filter((f) => f.endsWith('.md')); + + const updated = []; + + for (const file of mdFiles) { + const filePath = join(DOCS_DIR, file); + const content = await readFile(filePath, 'utf-8'); + const { attrs, body } = parseFrontmatter(content); + + // Check if this doc tracks the source repo. + if ( + !attrs.sources + || typeof attrs.sources !== 'object' + || !attrs.sources[sourceRepo] + ) { + // eslint-disable-next-line no-continue + continue; + } + + const source = attrs.sources[sourceRepo]; + const lastReviewed = source.lastReviewedCommit; + if (!lastReviewed) { + console.log(`⚠ ${file}: no lastReviewedCommit, skipping`); + // eslint-disable-next-line no-continue + continue; + } + + // Compute filtered diff. + const diff = getFilteredDiff(sourceRepoPath, lastReviewed, sourceRef); + + if (isTrivialDiff(diff)) { + console.log(`⏭ ${file}: no meaningful changes since ${lastReviewed}`); + // Still bump the reviewed commit so we don't re-check next time. + source.lastReviewedCommit = sourceRef; + if (sourceVersion) source.version = sourceVersion; + const bumped = serializeFrontmatter(attrs, body); + await writeFile(filePath, bumped, 'utf-8'); + // eslint-disable-next-line no-continue + continue; + } + + console.log(`📝 ${file}: updating from ${lastReviewed} → ${sourceRef}`); + + const systemPrompt = [ + 'You are a technical documentation writer for a developer platform.', + 'You update existing narrative documentation based on code changes.', + 'Return ONLY the complete updated Markdown file, including the YAML', + 'frontmatter block (between --- delimiters). Do not wrap your response', + 'in code fences or add any commentary outside the document itself.', + ].join(' '); + + const userPrompt = [ + '## Style guide\n', + styleGuide, + '\n\n## Current document\n', + content, + '\n\n## Code diff (source repo changes since last review)\n', + '```diff\n', + diff, + '\n```\n', + '\n## Instructions\n', + `Update the document above to reflect the code changes shown in the diff from the ${sourceRepo} repository.`, + 'Only modify sections that are affected by the diff.', + 'Preserve the existing writing style, structure, and tone.', + `In the frontmatter, set sources.${sourceRepo}.lastReviewedCommit to "${sourceRef}"`, + sourceVersion ? ` and sources.${sourceRepo}.version to "${sourceVersion}".` : '.', + `Also set sources.${sourceRepo}.lastContentCommit to "${sourceRef}".`, + 'Return the complete updated Markdown file including frontmatter.', + ].join(''); + + const result = await callLLM(systemPrompt, userPrompt, apiKey); + + // Strip wrapping code fences the LLM might add despite instructions. + const cleaned = result + .replace(/^```(?:markdown|md)?\s*\n?/, '') + .replace(/\n?```\s*$/, ''); + + await writeFile(filePath, cleaned, 'utf-8'); + updated.push(file); + } + + if (updated.length === 0) { + console.log('\n✅ No docs required content updates.'); + } else { + console.log(`\n✅ Updated ${updated.length} doc(s): ${updated.join(', ')}`); + } +} + +main();