diff --git a/.github/docs-sync/collect.mjs b/.github/docs-sync/collect.mjs new file mode 100644 index 00000000000..51be1a3d8e1 --- /dev/null +++ b/.github/docs-sync/collect.mjs @@ -0,0 +1,137 @@ +// kilocode_change - new file + +/** + * Collects PRs merged to the source repos since the watermark, applies a + * deterministic pre-filter, and writes docs-sync-out/digest.json for the LLM + * triage pass. + * + * Pre-filter drops (triage never sees these): + * - PRs labeled auto-docs (this bot's own rolling PRs) + * - chore/test/ci/build/docs/style/refactor/revert conventional titles + * - PRs touching only docs/non-product paths + * + * Bot-authored PRs are kept: release/dependency bots ship user-facing + * changes too, and the label + docs-only guards above prevent loops. + */ + +import fs from "node:fs" +import { api, appendOutput, appendSummary, listPrFiles, searchIssues } from "./lib.mjs" + +const SOURCE_REPOS = ["Kilo-Org/cloud", "Kilo-Org/kilocode"] +const OUT_DIR = "docs-sync-out" +const BODY_LIMIT = 2000 +const SLIM_BODY_LIMIT = 300 +const PATCH_LIMIT = 8000 +const FILE_LIMIT = 30 +const DROP_TITLE = /^(chore|test|ci|build|docs|style|refactor|revert)(\(.+\))?!?:/i +const DOCS_ONLY_PATH = /^(packages\/kilo-docs\/|\.github\/docs-sync\/|docs-sync-out\/|docs\/|[^/]+\.md$)/ + +function argSince() { + const i = process.argv.indexOf("--since") + const v = i >= 0 ? process.argv[i + 1] : null + if (!v || Number.isNaN(new Date(v).getTime())) { + throw new Error("usage: collect.mjs --since ") + } + return new Date(v) +} + +async function mergedPrs(fullRepo, since) { + const query = `repo:${fullRepo} is:pr is:merged merged:>=${since.toISOString()}` + return searchIssues(query) +} + +const since = argSince() +console.log(`collecting PRs merged since ${since.toISOString()}`) + +const digest = [] +const dropped = { label: 0, title: 0, docs_only: 0, fetch_error: 0 } + +for (const fullRepo of SOURCE_REPOS) { + const prs = await mergedPrs(fullRepo, since) + console.log(`${fullRepo}: ${prs.length} merged PRs in window`) + + for (const item of prs) { + const author = item.user?.login ?? "" + if ((item.labels ?? []).some((l) => l.name === "auto-docs")) { + dropped.label++ + continue + } + if (DROP_TITLE.test(item.title ?? "")) { + dropped.title++ + continue + } + + const number = item.number + let pr + let files + try { + pr = await api(`/repos/${fullRepo}/pulls/${number}`) + files = await listPrFiles(fullRepo, number) + } catch (err) { + // Isolate per-PR failures: one dead PR must not abort the whole run. + console.warn(`::warning::skipping ${fullRepo}#${number}: ${err.message}`) + dropped.fetch_error++ + continue + } + // listPrFiles caps at 300 files; a truncated list can't support the + // docs-only classification, so keep such PRs and record the true total. + const truncated = files.length >= 300 + if (!truncated && files.length > 0 && files.every((f) => DOCS_ONLY_PATH.test(f.filename))) { + dropped.docs_only++ + continue + } + + let patch = "" + for (const f of files) { + if (!f.patch) continue + const chunk = `--- ${f.filename}\n${f.patch}\n` + if (patch.length + chunk.length > PATCH_LIMIT) { + patch += "\n... (diff truncated) ...\n" + break + } + patch += chunk + } + + digest.push({ + repo: fullRepo, + number, + title: pr.title, + url: pr.html_url, + author, + merged_at: pr.merged_at, + labels: (pr.labels ?? []).map((l) => l.name), + body: (pr.body ?? "").slice(0, BODY_LIMIT), + files: files.slice(0, FILE_LIMIT).map((f) => `${f.status} ${f.filename} (+${f.additions}/-${f.deletions})`), + files_total: pr.changed_files ?? files.length, + patch_excerpt: patch, + }) + } +} + +digest.sort((a, b) => new Date(a.merged_at) - new Date(b.merged_at)) + +fs.mkdirSync(OUT_DIR, { recursive: true }) +// Full digest (bodies + patch excerpts) is filtered down to docs-worthy PRs +// for the edit pass; the slim digest keeps the triage pass context small. +fs.writeFileSync(`${OUT_DIR}/digest-full.json`, JSON.stringify(digest, null, 2)) +const slim = digest.map(({ patch_excerpt, body, ...rest }) => ({ + ...rest, + body: body.slice(0, SLIM_BODY_LIMIT), +})) +fs.writeFileSync(`${OUT_DIR}/digest.json`, JSON.stringify(slim, null, 2)) + +console.log(`kept ${digest.length} PRs, dropped:`, dropped) +appendOutput("count", digest.length) +appendOutput("digest", `${OUT_DIR}/digest.json`) + +appendSummary( + [ + "### docs-sync collect", + "", + `- window: since \`${since.toISOString()}\``, + `- kept: **${digest.length}** PRs`, + `- dropped: ${dropped.label} auto-docs, ${dropped.title} title filter, ${dropped.docs_only} docs-only, ${dropped.fetch_error} fetch errors`, + "", + ...digest.map((d) => `- [${d.repo}#${d.number}](${d.url}) ${d.title}`), + ].join("\n"), +) diff --git a/.github/docs-sync/edit-prompt.md b/.github/docs-sync/edit-prompt.md new file mode 100644 index 00000000000..f0099e588f7 --- /dev/null +++ b/.github/docs-sync/edit-prompt.md @@ -0,0 +1,24 @@ +You are the Kilo Code documentation bot. You update the public product documentation in `packages/kilo-docs` (a Markdoc/Next.js site served at kilo.ai/docs) so it reflects recently merged PRs. You are handling one batch of PRs; the batch files and your output file are named at the end of these instructions. + +Before writing anything: + +1. Read `packages/kilo-docs/AGENTS.md` and `packages/kilo-docs/STYLE_GUIDE.md` and follow them exactly: Markdoc custom tags, the `/docs` prefix in image paths, navigation files under `lib/nav/`, redirect rules, and the generated-screenshot policy. +2. Read the attached batch files: the full-details file (PR title, body, file list, `patch_excerpt` diffs) and the triage file (docs-worthiness verdicts, target sections, priorities). + +For each PR in the batch, in priority order: + +- Find the most relevant existing docs page(s) and make minimal, precise updates in the style of the surrounding content. +- Create a new page only when no existing page fits; then add it to the matching nav file in `packages/kilo-docs/lib/nav/`. +- Document only behavior that is actually present in the merged diff. If the PR body or diff shows the feature is behind a flag or otherwise not user-visible yet, skip it and record why. +- If a PR turns out not to need documentation, skip it and record why. Trust evidence over the triage verdict. + +Hard rules: + +- Only create or modify files under `packages/kilo-docs/`. Never touch code, tests, config, images, or anything outside that directory. +- Never remove or rename pages. Never document unreleased behavior. Never copy internal PR discussion into the docs; write user-facing documentation. +- Do not run git commands and do not commit anything; automation handles git. +- Keep the change small and precise. Do not rewrite sections that are already accurate. + +When finished, write the summary JSON file named in the batch specifics below: a JSON array with exactly one entry per batch PR, consumed by automation (this file is never committed). Use `action` values like `updated `, `created `, or `skipped`. Example: + +[{"pr": 123, "url": "https://github.com/Kilo-Org/kilocode/pull/123", "action": "updated pages/code-with-ai/platforms/cli.md", "reason": "documented --variant flag"}, {"pr": 124, "url": "https://github.com/Kilo-Org/kilocode/pull/124", "action": "skipped", "reason": "feature behind unreleased flag"}] diff --git a/.github/docs-sync/edit.mjs b/.github/docs-sync/edit.mjs new file mode 100644 index 00000000000..52943de31ab --- /dev/null +++ b/.github/docs-sync/edit.mjs @@ -0,0 +1,122 @@ +// kilocode_change - new file + +/** + * Runs the LLM edit pass over docs-sync-out/worthy.json in batches. + * + * Batching bounds each `kilo run` context (a replay window can yield dozens + * of docs-worthy PRs with large diffs). Each batch gets its own CLI session + * and writes its own summary file; results are merged into + * docs-sync-out/edit-summary.json. A batch that fails is skipped with a + * warning — its PRs show up in the rolling PR body as skipped, so nothing + * fails silently. + * + * Env: EDIT_MODEL (provider/model), KILO_API_KEY (set by workflow; read natively by the kilo provider). + */ + +import { execFileSync } from "node:child_process" +import fs from "node:fs" +import path from "node:path" +import { fileURLToPath } from "node:url" + +const BATCH_SIZE = 5 +const ATTEMPTS = 2 +const OUT_DIR = "docs-sync-out" +export const SUMMARY_FILE = ".docs-sync-summary.json" + +const HERE = path.dirname(fileURLToPath(import.meta.url)) +const basePrompt = fs.readFileSync(path.join(HERE, "edit-prompt.md"), "utf8") +const model = process.env.EDIT_MODEL +if (!model) throw new Error("EDIT_MODEL is required") + +const worthy = JSON.parse(fs.readFileSync(`${OUT_DIR}/worthy.json`, "utf8")) +const triage = JSON.parse(fs.readFileSync(`${OUT_DIR}/triage.json`, "utf8")) +const priority = new Map(triage.map((e) => [e.url, e])) +const ordered = [...worthy].sort((a, b) => { + const rank = { high: 0, medium: 1, low: 2 } + return (rank[priority.get(a.url)?.priority] ?? 1) - (rank[priority.get(b.url)?.priority] ?? 1) +}) + +function editBatch(batch, index) { + const batchFile = `${OUT_DIR}/edit-batch-${index}.json` + const triageFile = `${OUT_DIR}/edit-batch-triage-${index}.json` + const summaryFile = `${OUT_DIR}/edit-summary-${index}.json` + fs.writeFileSync(batchFile, JSON.stringify(batch, null, 2)) + fs.writeFileSync( + triageFile, + JSON.stringify( + batch.map((d) => priority.get(d.url)).filter(Boolean), + null, + 2, + ), + ) + + const prompt = `${basePrompt} + +Batch specifics for this run: the PRs to handle are in the attached ${batchFile} (full details) and ${triageFile} (triage verdicts). Handle ONLY the PRs in these batch files. When finished, write your per-PR results in the summary JSON format described above to the file \`${summaryFile}\` (path relative to the repository root).` + + for (let attempt = 1; attempt <= ATTEMPTS; attempt++) { + try { + // Message positional first: --file is multi-value and would otherwise + // consume a trailing message as a file path ("File not found"). + execFileSync( + "kilo", + ["run", prompt, "-m", model, "--variant", "high", "--dir", process.cwd(), "-f", batchFile, "-f", triageFile], + // stdout streams live to the Actions log; stderr is piped so failure + // warnings can include the tail of the actual CLI error. + { encoding: "utf8", maxBuffer: 32 * 1024 * 1024, timeout: 25 * 60 * 1000, stdio: ["ignore", "inherit", "pipe"] }, + ) + if (fs.existsSync(summaryFile)) return true + // Tolerate the agent dropping the docs-sync-out/ prefix. + const alt = path.basename(summaryFile) + if (fs.existsSync(alt)) { + fs.renameSync(alt, summaryFile) + return true + } + console.warn(`batch ${index} attempt ${attempt}: summary file ${summaryFile} not produced`) + } catch (err) { + const stderr = String(err.stderr ?? "").trim().split("\n").slice(-5).join("\n") + console.warn(`batch ${index} attempt ${attempt}: kilo run failed: ${stderr || err.message}`) + } + } + console.warn(`::warning::edit batch ${index} failed after ${ATTEMPTS} attempts; ${batch.length} PRs skipped`) + return false +} + +const batches = [] +for (let i = 0; i < ordered.length; i += BATCH_SIZE) { + batches.push(ordered.slice(i, i + BATCH_SIZE)) +} +console.log(`editing docs for ${ordered.length} PRs in ${batches.length} batches of up to ${BATCH_SIZE}`) + +for (let i = 0; i < batches.length; i++) { + editBatch(batches[i], i) +} + +// Merge batch summaries. Coverage: every worthy PR gets an entry so the PR +// body accounts for it; failed batches show up as skipped. +const merged = [] +const seen = new Set() +for (let i = 0; i < batches.length; i++) { + const file = `${OUT_DIR}/edit-summary-${i}.json` + let entries = [] + try { + entries = JSON.parse(fs.readFileSync(file, "utf8")) + } catch { + continue + } + for (const e of entries) { + const url = String(e?.url ?? "") + if (!url.startsWith("http") || seen.has(url)) continue + seen.add(url) + merged.push({ pr: Number(e.pr) || 0, url, action: String(e.action ?? "skipped"), reason: String(e.reason ?? "") }) + } +} +for (const d of ordered) { + if (seen.has(d.url)) continue + merged.push({ pr: d.number, url: d.url, action: "skipped", reason: "edit pass failed or timed out for this PR" }) +} + +// upsert-pr.mjs consumes the merged summary from the repo root; the file is +// removed there before committing so it never lands in the docs PR. +fs.writeFileSync(SUMMARY_FILE, JSON.stringify(merged, null, 2)) +console.log(`edit pass complete: ${merged.filter((e) => e.action !== "skipped").length} changed, ${merged.filter((e) => e.action === "skipped").length} skipped`) diff --git a/.github/docs-sync/extract-json.mjs b/.github/docs-sync/extract-json.mjs new file mode 100644 index 00000000000..c8cdf04ff12 --- /dev/null +++ b/.github/docs-sync/extract-json.mjs @@ -0,0 +1,76 @@ +// kilocode_change - new file + +/** + * Extracts and validates the triage JSON array from raw LLM stdout. + * Usage: extract-json.mjs + * Exit 0 on success, 1 on any failure. Also exports parseTriageEntries for + * the chunked triage runner. + */ + +import fs from "node:fs" +import { pathToFileURL } from "node:url" + +/** Returns validated triage entries, or null when extraction fails. */ +export function parseTriageEntries(raw) { + // `kilo run` prints the assistant message twice (streaming render + final + // summary), so stdout can hold the same array back-to-back. Try each "[" + // from the right and return the first slice that parses — i.e. the last + // (most recent) valid array in the output. + const end = raw.lastIndexOf("]") + if (end < 0) return null + + const starts = [] + for (let i = 0; i <= end; i++) { + if (raw[i] === "[") starts.push(i) + } + + for (let s = starts.length - 1; s >= 0; s--) { + let parsed + try { + parsed = JSON.parse(raw.slice(starts[s], end + 1)) + } catch { + continue + } + if (!Array.isArray(parsed)) continue + const entries = validate(parsed) + if (entries) return entries + } + return null +} + +function validate(parsed) { + const entries = [] + for (const e of parsed) { + const pr = Number(e?.pr) + const url = String(e?.url ?? "") + if (!Number.isInteger(pr) || !url.startsWith("http")) continue + entries.push({ + pr, + url, + docs_worthy: e.docs_worthy === true, + reason: String(e.reason ?? ""), + target_sections: Array.isArray(e.target_sections) ? e.target_sections.map(String) : [], + priority: ["high", "medium", "low"].includes(e.priority) ? e.priority : "medium", + }) + } + return entries.length > 0 ? entries : null +} + +function main() { + const [, , inputPath, outputPath] = process.argv + if (!inputPath || !outputPath) { + console.error("usage: extract-json.mjs ") + process.exit(1) + } + const entries = parseTriageEntries(fs.readFileSync(inputPath, "utf8")) + if (!entries) { + console.error("no valid triage JSON array found in input") + process.exit(1) + } + fs.writeFileSync(outputPath, JSON.stringify(entries, null, 2)) + console.log(`extracted ${entries.length} triage entries (${entries.filter((e) => e.docs_worthy).length} docs-worthy)`) +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main() +} diff --git a/.github/docs-sync/filter-worthy.mjs b/.github/docs-sync/filter-worthy.mjs new file mode 100644 index 00000000000..ea99f3d2589 --- /dev/null +++ b/.github/docs-sync/filter-worthy.mjs @@ -0,0 +1,24 @@ +// kilocode_change - new file + +/** + * Filters the full digest down to PRs the triage pass marked docs-worthy. + * Usage: filter-worthy.mjs + * The edit pass consumes the output so its context stays small. + */ + +import fs from "node:fs" + +const [, , digestPath, triagePath, outputPath] = process.argv +if (!digestPath || !triagePath || !outputPath) { + console.error("usage: filter-worthy.mjs ") + process.exit(1) +} + +const digest = JSON.parse(fs.readFileSync(digestPath, "utf8")) +const triage = JSON.parse(fs.readFileSync(triagePath, "utf8")) + +const worthy = new Set(triage.filter((e) => e.docs_worthy).map((e) => e.url)) +const out = digest.filter((d) => worthy.has(d.url)) + +fs.writeFileSync(outputPath, JSON.stringify(out, null, 2)) +console.log(`${out.length} of ${digest.length} digest entries are docs-worthy`) diff --git a/.github/docs-sync/lib.mjs b/.github/docs-sync/lib.mjs new file mode 100644 index 00000000000..56dfb7b9226 --- /dev/null +++ b/.github/docs-sync/lib.mjs @@ -0,0 +1,113 @@ +// kilocode_change - new file + +/** + * Shared helpers for the docs-sync bot scripts. Dependency-free (Node 20+ + * global fetch) so the workflow does not rely on runner images shipping the + * gh CLI. + */ + +import fs from "node:fs" + +const API = "https://api.github.com" +const MAX_RETRIES = 3 + +export function token() { + const t = process.env.GH_TOKEN || process.env.GITHUB_TOKEN + if (!t) throw new Error("GH_TOKEN (or GITHUB_TOKEN) is required") + return t +} + +export function repo() { + const r = process.env.GITHUB_REPOSITORY + if (!r) throw new Error("GITHUB_REPOSITORY is required") + return r +} + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)) + +export async function api(path, { method = "GET", body } = {}) { + for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) { + let res + try { + res = await fetch(`${API}${path}`, { + method, + headers: { + authorization: `Bearer ${token()}`, + accept: "application/vnd.github+json", + "x-github-api-version": "2022-11-28", + "user-agent": "kilo-docs-sync-bot", + }, + body: body === undefined ? undefined : JSON.stringify(body), + }) + } catch (err) { + if (attempt < MAX_RETRIES) { + console.warn(`network error (${err.message}), retrying in ${5 * attempt}s`) + await sleep(5000 * attempt) + continue + } + throw err + } + + if (res.status === 403) { + const text = await res.text() + if (text.includes("rate limit") && attempt < MAX_RETRIES) { + const retryAfter = Number(res.headers.get("retry-after")) || 30 + console.warn(`rate limited, retrying in ${retryAfter}s`) + await sleep(retryAfter * 1000) + continue + } + const err = new Error(`${method} ${path} -> 403: ${text}`) + err.status = 403 + throw err + } + + if (res.status >= 500 && attempt < MAX_RETRIES) { + console.warn(`${method} ${path} -> ${res.status}, retrying in ${5 * attempt}s`) + await sleep(5000 * attempt) + continue + } + + if (!res.ok) { + const text = await res.text() + const err = new Error(`${method} ${path} -> ${res.status}: ${text}`) + err.status = res.status + throw err + } + + if (res.status === 204) return null + return res.json() + } + throw new Error(`${method} ${path}: exhausted retries`) +} + +/** Paginated search/issues. Caps at `maxPages` * 100 results. */ +export async function searchIssues(query, { maxPages = 5 } = {}) { + const items = [] + for (let page = 1; page <= maxPages; page++) { + const data = await api(`/search/issues?q=${encodeURIComponent(query)}&per_page=100&page=${page}`) + items.push(...(data.items ?? [])) + if ((data.items ?? []).length < 100) break + } + return items +} + +export async function listPrFiles(fullRepo, number, { maxPages = 3 } = {}) { + const files = [] + for (let page = 1; page <= maxPages; page++) { + const batch = await api(`/repos/${fullRepo}/pulls/${number}/files?per_page=100&page=${page}`) + files.push(...batch) + if (batch.length < 100) break + } + return files +} + +export function appendOutput(name, value) { + const out = process.env.GITHUB_OUTPUT + if (out) fs.appendFileSync(out, `${name}=${value}\n`) + console.log(`output ${name}=${value}`) +} + +export function appendSummary(markdown) { + const summary = process.env.GITHUB_STEP_SUMMARY + if (summary) fs.appendFileSync(summary, markdown + "\n") +} diff --git a/.github/docs-sync/prepare-branch.mjs b/.github/docs-sync/prepare-branch.mjs new file mode 100644 index 00000000000..2b57932c442 --- /dev/null +++ b/.github/docs-sync/prepare-branch.mjs @@ -0,0 +1,61 @@ +// kilocode_change - new file + +/** + * Prepares the rolling docs-sync branch before the edit pass: + * - an open auto-docs PR exists -> check out its head branch and merge + * origin/main (preserves any human commits on the branch) + * - otherwise -> fresh branch from origin/main (bot force-pushes later) + * + * Outputs: branch, mode (update|fresh), pr_number (empty when fresh). + */ + +import { execFileSync } from "node:child_process" +import { api, appendOutput, repo, searchIssues } from "./lib.mjs" + +export const DEFAULT_BRANCH = "docs/auto-sync" + +const git = (args) => execFileSync("git", args, { stdio: ["ignore", "pipe", "inherit"] }).toString().trim() + +const prs = await searchIssues(`repo:${repo()} is:pr is:open label:auto-docs sort:created-desc`, { maxPages: 1 }) + +let mode = "fresh" +let prNumber = "" +let branch = DEFAULT_BRANCH + +if (prs.length > 0) { + const pr = await api(`/repos/${repo()}/pulls/${prs[0].number}`) + branch = pr.head?.ref ?? DEFAULT_BRANCH + prNumber = String(pr.number) + git(["fetch", "origin", "main", branch]) + git(["checkout", branch]) + try { + git(["merge", "origin/main", "--no-edit"]) + mode = "update" + } catch { + console.warn(`merge of origin/main into ${branch} conflicted.`) + console.warn("Leaving the conflicted branch untouched so human commits are preserved; continuing on a fresh dated branch.") + git(["merge", "--abort"]) + branch = `${DEFAULT_BRANCH}-${new Date().toISOString().slice(0, 10)}` + try { + git(["fetch", "origin", `+refs/heads/${branch}:refs/remotes/origin/${branch}`]) + } catch { + console.log(`dated branch ${branch} does not exist on origin yet; will create it on push`) + } + git(["checkout", "-B", branch, "origin/main"]) + mode = "conflict" + } +} else { + // Keep the remote-tracking ref current so the later --force-with-lease + // push (stale branch left over from a merged/closed PR) is safe. + try { + git(["fetch", "origin", `+refs/heads/${branch}:refs/remotes/origin/${branch}`]) + } catch { + console.log(`branch ${branch} does not exist on origin yet; will create it on push`) + } + git(["checkout", "-B", branch, "origin/main"]) +} + +appendOutput("branch", branch) +appendOutput("mode", mode) +appendOutput("pr_number", prNumber) +console.log(`branch ${branch} ready (mode=${mode}, pr=${prNumber || "none"})`) diff --git a/.github/docs-sync/triage-prompt.md b/.github/docs-sync/triage-prompt.md new file mode 100644 index 00000000000..10e54406e89 --- /dev/null +++ b/.github/docs-sync/triage-prompt.md @@ -0,0 +1,19 @@ +You are the triage pass of an automated documentation pipeline for Kilo Code. Kilo Code is an open-source agentic engineering platform: VS Code extension, JetBrains plugin, CLI, and the kilo.ai cloud platform (teams, KiloClaw, gateway, code reviews). + +The attached `digest.json` file contains PRs recently merged to Kilo-Org/cloud and Kilo-Org/kilocode. Your only job is to decide which of them require changes to the public product documentation at kilo.ai/docs. + +A PR is docs-worthy ONLY if a user of Kilo Code would need to learn something new or change how they use the product after this PR ships. Examples: new commands, flags, settings, UI workflows, providers, pricing/limits changes, breaking behavior changes, or fixes that change documented behavior. + +A PR is NOT docs-worthy when it is: an internal refactor, infrastructure or CI work, a feature-flag scaffold that is not yet user-visible, test or dependency work, a bug fix that merely restores already-documented behavior, or a change only visible to contributors or self-hosters. + +Rules: + +- Include every input PR exactly once, identified by its `number` and `url`. Never invent PRs. +- When unsure, set `docs_worthy` to false and explain the doubt in `reason`. +- `target_sections` is only filled for docs-worthy PRs. Use rough docs areas, e.g. `getting-started`, `code-with-ai/platforms/cli`, `code-with-ai/platforms/vscode`, `code-with-ai/agents`, `ai-providers`, `teams`, `enterprise`, `automate`. +- `reason` is one short sentence, written for the human who reviews the final docs PR. +- `priority` reflects user impact: high = most users affected, medium = notable subset, low = edge case. + +Respond with a STRICT JSON array and nothing else: no prose, no markdown fences, no comments. Schema: + +[{"pr": 123, "url": "https://github.com/Kilo-Org/kilocode/pull/123", "docs_worthy": true, "reason": "Adds --variant flag to kilo run", "target_sections": ["code-with-ai/platforms/cli"], "priority": "high"}] diff --git a/.github/docs-sync/triage.mjs b/.github/docs-sync/triage.mjs new file mode 100644 index 00000000000..034a7ddcbda --- /dev/null +++ b/.github/docs-sync/triage.mjs @@ -0,0 +1,111 @@ +// kilocode_change - new file + +/** + * Runs the LLM triage pass over docs-sync-out/digest.json in chunks. + * + * A daily window holds ~30-50 PRs; a replay can hold several hundred. A + * single triage call over that volume truncates its JSON output, so the + * digest is split into chunks of CHUNK_SIZE and each chunk is triaged with + * its own `kilo run` call. A chunk that fails twice is degraded to + * "unclassified" entries (docs_worthy=false) instead of failing the run — + * the PR body then shows those PRs as skipped, visible to reviewers. + * + * Env: TRIAGE_MODEL (provider/model), KILO_API_KEY (gateway auth, set by the workflow; + * the kilo provider reads it natively). Reads the prompt from triage-prompt.md next to this script. + */ + +import { execFileSync } from "node:child_process" +import fs from "node:fs" +import path from "node:path" +import { fileURLToPath } from "node:url" +import { parseTriageEntries } from "./extract-json.mjs" + +const CHUNK_SIZE = 25 +const ATTEMPTS = 2 +const OUT_DIR = "docs-sync-out" + +const HERE = path.dirname(fileURLToPath(import.meta.url)) +const prompt = fs.readFileSync(path.join(HERE, "triage-prompt.md"), "utf8") +const model = process.env.TRIAGE_MODEL +if (!model) throw new Error("TRIAGE_MODEL is required") + +const digest = JSON.parse(fs.readFileSync(`${OUT_DIR}/digest.json`, "utf8")) + +function triageChunk(chunk, index) { + const chunkFile = `${OUT_DIR}/triage-chunk-${index}.json` + fs.writeFileSync(chunkFile, JSON.stringify(chunk, null, 2)) + + for (let attempt = 1; attempt <= ATTEMPTS; attempt++) { + let raw + try { + // Message positional first: --file is multi-value and would otherwise + // consume a trailing message as a file path ("File not found"). + raw = execFileSync( + "kilo", + ["run", prompt, "-m", model, "--dir", process.cwd(), "-f", chunkFile], + { encoding: "utf8", maxBuffer: 32 * 1024 * 1024, timeout: 10 * 60 * 1000, stdio: ["ignore", "pipe", "pipe"] }, + ) + } catch (err) { + const stderr = String(err.stderr ?? "").trim().split("\n").slice(-5).join("\n") + console.warn(`chunk ${index} attempt ${attempt}: kilo run failed: ${stderr || err.message}`) + continue + } + fs.writeFileSync(`${OUT_DIR}/triage-raw-${index}.txt`, raw) + const entries = parseTriageEntries(raw) + if (entries) { + // An entry for a PR outside this chunk must not win the shared dedupe + // against the chunk that actually owns it — drop foreign entries. + const allowed = new Set(chunk.map((d) => d.url)) + const owned = entries.filter((e) => allowed.has(e.url)) + if (owned.length !== entries.length) { + console.warn(`chunk ${index}: dropped ${entries.length - owned.length} entries for PRs outside the chunk`) + } + if (owned.length > 0) return owned + } + console.warn(`chunk ${index} attempt ${attempt}: no valid JSON in output`) + } + + console.warn(`::warning::chunk ${index} failed triage after ${ATTEMPTS} attempts; marking ${chunk.length} PRs unclassified`) + return chunk.map((d) => ({ + pr: d.number, + url: d.url, + docs_worthy: false, + reason: "triage failed to classify this PR", + target_sections: [], + priority: "medium", + })) +} + +const chunks = [] +for (let i = 0; i < digest.length; i += CHUNK_SIZE) { + chunks.push(digest.slice(i, i + CHUNK_SIZE)) +} +console.log(`triaging ${digest.length} PRs in ${chunks.length} chunks of up to ${CHUNK_SIZE}`) + +const merged = [] +const seen = new Set() +for (let i = 0; i < chunks.length; i++) { + for (const e of triageChunk(chunks[i], i)) { + if (seen.has(e.url)) continue + seen.add(e.url) + merged.push(e) + } +} + +// Coverage: every digest PR gets a triage entry so the PR body's skipped +// table is complete. Unclassified defaults to not-docs-worthy (conservative). +for (const d of digest) { + if (seen.has(d.url)) continue + merged.push({ + pr: d.number, + url: d.url, + docs_worthy: false, + reason: "not classified by triage", + target_sections: [], + priority: "medium", + }) +} + +fs.writeFileSync(`${OUT_DIR}/triage.json`, JSON.stringify(merged, null, 2)) +const worthy = merged.filter((e) => e.docs_worthy).length +console.log(`triage complete: ${merged.length} entries, ${worthy} docs-worthy`) diff --git a/.github/docs-sync/upsert-pr.mjs b/.github/docs-sync/upsert-pr.mjs new file mode 100644 index 00000000000..e39551f307c --- /dev/null +++ b/.github/docs-sync/upsert-pr.mjs @@ -0,0 +1,241 @@ +// kilocode_change - new file + +/** + * Commits the agent's packages/kilo-docs changes, pushes the rolling branch, + * and creates or updates the rolling auto-docs PR. + * + * No-op when the agent produced no docs changes. PRs become drafts when the + * diff exceeds the file cap or verification failed. The PR body carries + * marker-delimited sections so later runs can append rows, plus a + * machine-readable processed-through watermark. + */ + +import { execFileSync } from "node:child_process" +import fs from "node:fs" +import { pathToFileURL } from "node:url" + +const BRANCH = process.env.BRANCH || "docs/auto-sync" +const FILE_CAP = 15 +const ROW_CAP = 150 +const SUMMARY_FILE = ".docs-sync-summary.json" +const DOCS_PATH = "packages/kilo-docs" + +const git = (args) => execFileSync("git", args, { stdio: ["ignore", "pipe", "inherit"] }).toString().trim() + +// Agent-generated strings land in the PR body next to machine-read markers. +// Strip HTML-comment sequences so a crafted/adversarial value cannot forge +// section boundaries or the processed-through watermark. +function clean(value) { + return String(value ?? "").replaceAll("", "") +} + +function shortRef(url) { + return clean(url).replace("https://github.com/", "").replace("/pull/", "#") +} + +function changeRow(e) { + return `| ${clean(e.action).replaceAll("|", "\\|")} | [${shortRef(e.url)}](${clean(e.url)}) |` +} + +function skippedRow(e) { + const reason = clean(e.reason).replaceAll("|", "\\|").replaceAll("\n", " ") + return `| [${shortRef(e.url)}](${clean(e.url)}) | ${reason} |` +} + +export function extractSectionRows(body, name) { + const m = String(body ?? "").match( + new RegExp(`([\\s\\S]*?)`), + ) + if (!m) return [] + return m[1] + .split("\n") + .map((l) => l.trim()) + .filter((l) => l.startsWith("|") && !l.startsWith("| ---") && !/^\|\s*Docs change/.test(l) && !/^\|\s*PR\s*\|/.test(l)) +} + +function section(name, header, rows) { + const body = rows.length > 0 ? [header, "| --- | --- |", ...rows].join("\n") : "_None._" + return `\n${body}\n` +} + +export function renderBody({ date, since, through, changesRows, skippedRows, verified, draftReasons, note }) { + return `## Automated docs sync — ${date} + +This PR keeps kilo.ai/docs in sync with features merged to [Kilo-Org/cloud](https://github.com/Kilo-Org/cloud) and [Kilo-Org/kilocode](https://github.com/Kilo-Org/kilocode). Every change below links to the merged PR it documents. + +- Window: \`${since}\` → \`${through}\` +- Verification (docs build + tests): **${verified ? "passing" : "FAILING — needs a human look"}** +${note ? `- ${note}\n` : ""}${draftReasons.length > 0 ? `- Draft because: ${draftReasons.join("; ")}\n` : ""} +### Changes + +${section("changes", "| Docs change | Source |", changesRows)} + +### Considered, no docs change needed + +${section("skipped", "| PR | Reason |", skippedRows)} + +--- + +(bot) Generated by the docs-sync workflow. Humans review and merge; while this PR stays open, the next daily run appends new changes here. Branch: \`${BRANCH}\`. + +` +} + +function mergeRows(oldRows, newRows) { + const seen = new Set() + const out = [] + for (const row of [...oldRows, ...newRows]) { + if (seen.has(row)) continue + seen.add(row) + out.push(row) + } + return out.slice(-ROW_CAP) +} + +function readJson(path, fallback) { + try { + return JSON.parse(fs.readFileSync(path, "utf8")) + } catch { + return fallback + } +} + +async function main() { + const { api, appendOutput, appendSummary, repo } = await import("./lib.mjs") + + const through = process.env.PROCESSED_THROUGH ?? new Date().toISOString() + const since = process.env.SINCE ?? "unknown" + const mode = ["update", "conflict"].includes(process.env.PREP_MODE) ? process.env.PREP_MODE : "fresh" + const existingPr = process.env.PR_NUMBER || "" + const verified = process.env.VERIFIED === "true" + const date = through.slice(0, 10) + + // The agent's run summary is consumed here and never committed. + const agentSummary = readJson(SUMMARY_FILE, []) + fs.rmSync(SUMMARY_FILE, { force: true }) + const triage = readJson("docs-sync-out/triage.json", []) + + if (git(["status", "--porcelain", "--", DOCS_PATH]) === "") { + console.log("no packages/kilo-docs changes produced; nothing to commit") + appendSummary("### docs-sync: no docs changes\n\nThe agent found nothing worth documenting in this window.") + return + } + + git(["config", "user.name", "github-actions[bot]"]) + git(["config", "user.email", "41898282+github-actions[bot]@users.noreply.github.com"]) + git(["add", DOCS_PATH]) + git(["commit", "-m", `docs: sync with merged PRs (${date})`]) + + // The draft cap bounds the cumulative PR diff, not just this run's commit. + const changedFiles = git(["diff", "--name-only", "origin/main...HEAD", "--", DOCS_PATH]) + .split("\n") + .filter(Boolean) + const draftReasons = [] + if (changedFiles.length > FILE_CAP) draftReasons.push(`diff exceeds ${FILE_CAP} files (${changedFiles.length})`) + if (!verified) draftReasons.push("docs build/tests not passing") + // Content gate: legitimate bot edits are docs pages and nav files. Anything + // else in the docs package (build config, components, tests) executes + // during the verify build, so force human review before merge. + const nonContent = changedFiles.filter( + (f) => !f.startsWith("packages/kilo-docs/pages/") && !f.startsWith("packages/kilo-docs/lib/nav/"), + ) + if (nonContent.length > 0) { + // File paths are agent-chosen; sanitize before they land in the PR body. + const listed = nonContent + .slice(0, 5) + .map((f) => clean(f).replaceAll("|", "\\|")) + .join(", ") + draftReasons.push(`touches non-content files outside pages/ and lib/nav/: ${listed}`) + } + const draft = draftReasons.length > 0 + + git(mode === "update" ? ["push", "origin", `HEAD:${BRANCH}`] : ["push", "--force-with-lease", "origin", `HEAD:${BRANCH}`]) + + const changesNew = agentSummary.filter((e) => e.action !== "skipped").map(changeRow) + const skippedNew = [ + ...triage.filter((e) => e.docs_worthy === false), + ...agentSummary.filter((e) => e.action === "skipped"), + ].map(skippedRow) + + let oldChanges = [] + let oldSkipped = [] + if (mode === "update" && existingPr) { + const pr = await api(`/repos/${repo()}/pulls/${existingPr}`) + oldChanges = extractSectionRows(pr.body, "changes") + oldSkipped = extractSectionRows(pr.body, "skipped") + } + + const body = renderBody({ + date, + since, + through, + changesRows: mergeRows(oldChanges, changesNew), + skippedRows: mergeRows(oldSkipped, skippedNew), + verified, + draftReasons, + note: + mode === "conflict" && existingPr + ? `Continues from #${existingPr}, whose branch conflicted with \`main\` (its commits are preserved there).` + : "", + }) + + try { + await api(`/repos/${repo()}/labels`, { + method: "POST", + body: { name: "auto-docs", color: "1d76db", description: "Automated docs-sync PRs" }, + }) + } catch (err) { + if (err.status !== 422) throw err // 422 = label already exists + } + + let prNumber + let prUrl + if (mode === "update" && existingPr) { + const pr = await api(`/repos/${repo()}/pulls/${existingPr}`, { + method: "PATCH", + body: { title: `docs: auto-sync with merged PRs (through ${date})`, body }, + }) + prNumber = pr.number + prUrl = pr.html_url + await api(`/repos/${repo()}/issues/${prNumber}/comments`, { + method: "POST", + body: { + body: `(bot) Appended changes processed through \`${through}\`. Verification: **${verified ? "passing" : "failing"}**.${draft ? ` Draft because: ${draftReasons.join("; ")}.` : ""}`, + }, + }) + } else { + const pr = await api(`/repos/${repo()}/pulls`, { + method: "POST", + body: { + title: `docs: auto-sync with merged PRs (through ${date})`, + head: BRANCH, + base: "main", + body, + draft, + }, + }) + prNumber = pr.number + prUrl = pr.html_url + await api(`/repos/${repo()}/issues/${prNumber}/labels`, { method: "POST", body: { labels: ["auto-docs"] } }) + if (mode === "conflict" && existingPr) { + await api(`/repos/${repo()}/issues/${existingPr}/comments`, { + method: "POST", + body: { + body: `(bot) This branch conflicted with \`main\`, so the sync continues in ${prUrl}. Commits on this branch are preserved — please close this PR after the new one is reviewed.`, + }, + }) + } + } + + appendOutput("pr_url", prUrl) + appendSummary(`### docs-sync PR\n\n- ${prUrl}\n- changed files: ${changedFiles.length}\n- draft: ${draft}\n`) + console.log(`PR ${prNumber}: ${prUrl} (draft=${draft}, files=${changedFiles.length})`) +} + +const isMain = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href +if (isMain) { + main().catch((err) => { + console.error(err) + process.exit(1) + }) +} diff --git a/.github/docs-sync/watermark.mjs b/.github/docs-sync/watermark.mjs new file mode 100644 index 00000000000..cc4c9690168 --- /dev/null +++ b/.github/docs-sync/watermark.mjs @@ -0,0 +1,75 @@ +// kilocode_change - new file + +/** + * Resolves the docs-sync watermark: the timestamp of the newest source PR the + * bot has already processed. Derived from the bot's own PRs (marker in the PR + * body), so there is no external state to keep consistent. + * + * Priority: workflow_dispatch input `since` > latest open bot PR marker > + * last merged bot PR marker > 72h ago. Hard cap: never look back more than + * 14 days. + */ + +import { appendOutput, appendSummary, repo, searchIssues } from "./lib.mjs" + +const FALLBACK_HOURS = 72 +const CAP_DAYS = 14 +const MARKER = // + +function extractMarker(body) { + const m = (body ?? "").match(MARKER) + if (!m) return null + const d = new Date(m[1]) + return Number.isNaN(d.getTime()) ? null : d +} + +async function findWatermark() { + const r = repo() + for (const state of ["open", "merged"]) { + const query = `repo:${r} is:pr label:auto-docs sort:created-desc ${state === "open" ? "is:open" : "is:merged"}` + const prs = await searchIssues(query, { maxPages: 1 }) + for (const pr of prs) { + // Only trust markers on PRs authored by the bot itself: bodies are + // editable and the label can be applied by anyone with triage access. + if (pr.user?.login !== "github-actions[bot]") continue + const marker = extractMarker(pr.body) + if (marker) { + console.log(`watermark from ${state} PR #${pr.number}: ${marker.toISOString()}`) + return marker + } + } + } + return null +} + +const now = new Date() +let since + +const input = (process.env.INPUT_SINCE ?? "").trim() +if (input) { + since = new Date(input) + if (Number.isNaN(since.getTime())) { + throw new Error(`Invalid INPUT_SINCE: ${input}`) + } + console.log(`watermark from dispatch input: ${since.toISOString()}`) +} else { + since = + (await findWatermark()) ?? new Date(now.getTime() - FALLBACK_HOURS * 3600 * 1000) +} + +// A forged, edited, or malformed marker in the future would silently match +// nothing in the merged:>= search; clamp it loudly. +if (since > now) { + console.warn(`watermark ${since.toISOString()} is in the future, clamping to now`) + since = now +} + +const cap = new Date(now.getTime() - CAP_DAYS * 24 * 3600 * 1000) +if (since < cap) { + console.log(`watermark ${since.toISOString()} older than ${CAP_DAYS}d cap, clamping`) + since = cap +} + +appendOutput("since", since.toISOString()) +appendOutput("now", now.toISOString()) +appendSummary(`### docs-sync watermark\n\n- since: \`${since.toISOString()}\`\n- now: \`${now.toISOString()}\`\n`) diff --git a/.github/workflows/docs-sync.yml b/.github/workflows/docs-sync.yml new file mode 100644 index 00000000000..9eacf14e614 --- /dev/null +++ b/.github/workflows/docs-sync.yml @@ -0,0 +1,166 @@ +# kilocode_change - new file +name: docs-sync + +# Daily bot: collects PRs merged to Kilo-Org/cloud and Kilo-Org/kilocode, +# triages them for docs relevance, runs Kilo CLI headless to update +# packages/kilo-docs, and maintains one rolling PR for human review. +# +# Security posture: scheduled/manual only, checks out main, never executes +# code from PR branches. State is derived from the bot's own PRs (watermark +# marker in the PR body), so missed or failed runs self-heal on the next run. + +on: + schedule: + - cron: "0 7 * * *" # 07:00 UTC daily + workflow_dispatch: + inputs: + since: + description: "Override watermark (ISO date, e.g. 2026-07-20). Default: last processed-through marker, 72h fallback, 14d cap." + required: false + type: string + dry_run: + description: "Collect + triage only, no edits, no PR" + type: boolean + default: false + +permissions: + contents: write # push the rolling branch, create the auto-docs label + pull-requests: write # create/update the rolling PR + issues: write # comment on the rolling PR + +concurrency: + group: docs-sync + cancel-in-progress: false + +env: + TRIAGE_MODEL: ${{ vars.DOCS_SYNC_TRIAGE_MODEL || 'kilo/moonshotai/kimi-k3' }} + EDIT_MODEL: ${{ vars.DOCS_SYNC_EDIT_MODEL || 'kilo/moonshotai/kimi-k3' }} + +jobs: + sync: + if: github.repository == 'Kilo-Org/kilocode' + runs-on: blacksmith-4vcpu-ubuntu-2404 + timeout-minutes: 120 + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + fetch-depth: 0 # prepare-branch merges main into the rolling branch + + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version: "24" + package-manager-cache: false + + - name: Install Kilo CLI + run: | + npm install -g @kilocode/cli + kilo --version + + - name: Resolve watermark + id: wm + env: + GH_TOKEN: ${{ github.token }} + INPUT_SINCE: ${{ inputs.since }} + run: node .github/docs-sync/watermark.mjs + + - name: Collect merged PRs + id: collect + env: + GH_TOKEN: ${{ github.token }} + run: node .github/docs-sync/collect.mjs --since "${{ steps.wm.outputs.since }}" + + - name: Triage merged PRs (LLM, chunked) + id: triage + if: steps.collect.outputs.count != '0' + env: + KILO_API_KEY: ${{ secrets.KILO_API_KEY }} + run: node .github/docs-sync/triage.mjs + + - name: Filter docs-worthy PRs + id: worthy + if: steps.collect.outputs.count != '0' + run: | + node .github/docs-sync/filter-worthy.mjs \ + docs-sync-out/digest-full.json docs-sync-out/triage.json docs-sync-out/worthy.json + count=$(node -p "require('./docs-sync-out/worthy.json').length") + echo "count=$count" >> "$GITHUB_OUTPUT" + if [ "$count" = "0" ]; then + echo "No docs-worthy PRs in this window; skipping edit/verify/PR." + fi + + - name: Setup Bun + if: (steps.worthy.outputs.count || '0') != '0' && inputs.dry_run != true + uses: ./.github/actions/setup-bun + + - name: Prepare rolling branch + id: prep + if: (steps.worthy.outputs.count || '0') != '0' && inputs.dry_run != true + env: + GH_TOKEN: ${{ github.token }} + run: node .github/docs-sync/prepare-branch.mjs + + - name: Update docs (Kilo CLI, batched) + if: (steps.worthy.outputs.count || '0') != '0' && inputs.dry_run != true + env: + KILO_API_KEY: ${{ secrets.KILO_API_KEY }} + run: node .github/docs-sync/edit.mjs + + - name: Verify docs build and tests + id: verify + if: (steps.worthy.outputs.count || '0') != '0' && inputs.dry_run != true + continue-on-error: true + env: + NEXT_PUBLIC_POSTHOG_KEY: ${{ secrets.POSTHOG_API_KEY }} + run: | + set -o pipefail + { bun run --filter @kilocode/kilo-docs build && bun run --filter @kilocode/kilo-docs test; } 2>&1 | tee docs-sync-out/verify.log + + - name: Fix verify failures (one pass) + id: fix + if: steps.verify.outcome == 'failure' + continue-on-error: true + env: + KILO_API_KEY: ${{ secrets.KILO_API_KEY }} + NEXT_PUBLIC_POSTHOG_KEY: ${{ secrets.POSTHOG_API_KEY }} + run: | + set -o pipefail + kilo run "The docs build or tests failed. Read the attached docs-sync-out/verify.log and fix the packages/kilo-docs changes so they pass. Do not revert doc edits; fix them. Do not modify anything outside packages/kilo-docs." \ + -m "$EDIT_MODEL" --dir "$GITHUB_WORKSPACE" -f docs-sync-out/verify.log \ + | tee -a docs-sync-out/edit-log.txt + { bun run --filter @kilocode/kilo-docs build && bun run --filter @kilocode/kilo-docs test; } 2>&1 | tee docs-sync-out/verify2.log + + - name: Re-verify status + id: verified + if: (steps.worthy.outputs.count || '0') != '0' && inputs.dry_run != true + env: + VERIFY_OUTCOME: ${{ steps.verify.outcome }} + FIX_OUTCOME: ${{ steps.fix.outcome }} + run: | + if [ "$VERIFY_OUTCOME" = "success" ] || [ "$FIX_OUTCOME" = "success" ]; then + echo "ok=true" >> "$GITHUB_OUTPUT" + else + echo "ok=false" >> "$GITHUB_OUTPUT" + fi + + - name: Upsert rolling PR + if: (steps.worthy.outputs.count || '0') != '0' && inputs.dry_run != true + env: + GH_TOKEN: ${{ github.token }} + PROCESSED_THROUGH: ${{ steps.wm.outputs.now }} + SINCE: ${{ steps.wm.outputs.since }} + BRANCH: ${{ steps.prep.outputs.branch }} + PREP_MODE: ${{ steps.prep.outputs.mode }} + PR_NUMBER: ${{ steps.prep.outputs.pr_number }} + VERIFIED: ${{ steps.verified.outputs.ok }} + run: node .github/docs-sync/upsert-pr.mjs + + - name: Upload run artifacts + if: always() + uses: actions/upload-artifact@v4 + with: + name: docs-sync-out + path: docs-sync-out/ + retention-days: 14 + if-no-files-found: ignore diff --git a/script/check-workflows.ts b/script/check-workflows.ts index c429937bdc0..fc69503efb8 100644 --- a/script/check-workflows.ts +++ b/script/check-workflows.ts @@ -39,6 +39,7 @@ const active = new Set([ "containers.yml", "docs-build.yml", "docs-check-links.yml", + "docs-sync.yml", "generate.yml", "kilo-auto-close.yml", "nix-eval.yml",