From 405ed743b5a5294faa9a72cfa1b8205e97b992a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 24 Jul 2026 14:09:07 +0200 Subject: [PATCH 1/7] feat: daily docs-sync bot workflow (Kilo CLI) Adds a scheduled workflow that keeps packages/kilo-docs in sync with PRs merged to Kilo-Org/cloud and Kilo-Org/kilocode: - watermark.mjs derives the processing window from the bot's own PR body marker (self-healing, no external state; 72h fallback, 14d cap) - collect.mjs queries merged PRs via the GitHub API and applies a deterministic pre-filter (bots, chores, docs-only PRs) - triage.mjs classifies PRs in chunks of 25 with kilo run; failed chunks degrade to unclassified instead of failing the run - edit.mjs updates docs in batches of 5 PRs with kilo run, bounded per batch; failures surface as skipped entries in the PR body - verify runs the kilo-docs build + test suite; one LLM fix pass on failure; still-red becomes a draft PR - upsert-pr.mjs maintains one rolling auto-docs PR (appends while open, fresh branch after merge), with a 15-file draft cap and a machine-readable processed-through watermark Also adds docs-sync.yml to the workflow allowlist in script/check-workflows.ts. --- .github/docs-sync/collect.mjs | 127 +++++++++++++++++ .github/docs-sync/edit-prompt.md | 24 ++++ .github/docs-sync/edit.mjs | 111 +++++++++++++++ .github/docs-sync/extract-json.mjs | 61 ++++++++ .github/docs-sync/filter-worthy.mjs | 24 ++++ .github/docs-sync/lib.mjs | 95 +++++++++++++ .github/docs-sync/prepare-branch.mjs | 53 +++++++ .github/docs-sync/triage-prompt.md | 19 +++ .github/docs-sync/triage.mjs | 99 +++++++++++++ .github/docs-sync/upsert-pr.mjs | 205 +++++++++++++++++++++++++++ .github/docs-sync/watermark.mjs | 65 +++++++++ .github/workflows/docs-sync.yml | 167 ++++++++++++++++++++++ script/check-workflows.ts | 1 + 13 files changed, 1051 insertions(+) create mode 100644 .github/docs-sync/collect.mjs create mode 100644 .github/docs-sync/edit-prompt.md create mode 100644 .github/docs-sync/edit.mjs create mode 100644 .github/docs-sync/extract-json.mjs create mode 100644 .github/docs-sync/filter-worthy.mjs create mode 100644 .github/docs-sync/lib.mjs create mode 100644 .github/docs-sync/prepare-branch.mjs create mode 100644 .github/docs-sync/triage-prompt.md create mode 100644 .github/docs-sync/triage.mjs create mode 100644 .github/docs-sync/upsert-pr.mjs create mode 100644 .github/docs-sync/watermark.mjs create mode 100644 .github/workflows/docs-sync.yml diff --git a/.github/docs-sync/collect.mjs b/.github/docs-sync/collect.mjs new file mode 100644 index 00000000000..686a57cb548 --- /dev/null +++ b/.github/docs-sync/collect.mjs @@ -0,0 +1,127 @@ +// 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): + * - bot-authored PRs (includes this bot's own rolling PRs) + * - PRs labeled auto-docs + * - chore/test/ci/build/docs/style/refactor/revert conventional titles + * - PRs touching only docs/non-product paths + */ + +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 = { bot: 0, label: 0, title: 0, docs_only: 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 (author.endsWith("[bot]")) { + dropped.bot++ + continue + } + 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 + const pr = await api(`/repos/${fullRepo}/pulls/${number}`) + const files = await listPrFiles(fullRepo, number) + if (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: 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.bot} bot, ${dropped.label} auto-docs, ${dropped.title} title filter, ${dropped.docs_only} docs-only`, + "", + ...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..1ecc081eae7 --- /dev/null +++ b/.github/docs-sync/edit.mjs @@ -0,0 +1,111 @@ +// 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_CONFIG_CONTENT (set by workflow). + */ + +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). Write your per-PR results to ${summaryFile} at the repository root in the summary JSON format described above. Handle ONLY the PRs in these batch files.` + + for (let attempt = 1; attempt <= ATTEMPTS; attempt++) { + try { + execFileSync( + "kilo", + ["run", "-m", model, "--variant", "high", "--dir", process.cwd(), "-f", batchFile, "-f", triageFile, prompt], + { encoding: "utf8", maxBuffer: 32 * 1024 * 1024, timeout: 25 * 60 * 1000, stdio: ["ignore", "inherit", "inherit"] }, + ) + if (fs.existsSync(summaryFile)) return true + console.warn(`batch ${index} attempt ${attempt}: summary file ${summaryFile} not produced`) + } catch (err) { + console.warn(`batch ${index} attempt ${attempt}: kilo run failed: ${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..7dfc052bc73 --- /dev/null +++ b/.github/docs-sync/extract-json.mjs @@ -0,0 +1,61 @@ +// 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) { + const start = raw.indexOf("[") + const end = raw.lastIndexOf("]") + if (start < 0 || end <= start) return null + + let parsed + try { + parsed = JSON.parse(raw.slice(start, end + 1)) + } catch { + return null + } + if (!Array.isArray(parsed)) return null + + 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..a88d22fd948 --- /dev/null +++ b/.github/docs-sync/lib.mjs @@ -0,0 +1,95 @@ +// 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++) { + const 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), + }) + + if (res.status === 403 && attempt < MAX_RETRIES) { + const text = await res.text() + if (text.includes("rate limit")) { + const retryAfter = Number(res.headers.get("retry-after")) || 30 + console.warn(`rate limited, retrying in ${retryAfter}s`) + await sleep(retryAfter * 1000) + continue + } + throw new Error(`${method} ${path} -> 403: ${text}`) + } + + 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..2c0d11e9659 --- /dev/null +++ b/.github/docs-sync/prepare-branch.mjs @@ -0,0 +1,53 @@ +// kilocode_change - new file + +/** + * Prepares the rolling docs-sync branch before the edit pass: + * - an open auto-docs PR exists -> check out its 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 { appendOutput, repo, searchIssues } from "./lib.mjs" + +export const 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 = "" + +if (prs.length > 0) { + const pr = prs[0] + 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; resetting branch to origin/main.`) + console.warn("Unmerged work on the previous branch is re-derived from the watermark window.") + git(["merge", "--abort"]) + git(["checkout", "-B", BRANCH, "origin/main"]) + mode = "fresh" + } +} 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 { + // branch does not exist on origin yet — fine + } + 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..7508f9ba004 --- /dev/null +++ b/.github/docs-sync/triage.mjs @@ -0,0 +1,99 @@ +// 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_CONFIG_CONTENT (CLI auth, set by + * the workflow). 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 { + raw = execFileSync( + "kilo", + ["run", "-m", model, "--dir", process.cwd(), "-f", chunkFile, prompt], + { encoding: "utf8", maxBuffer: 32 * 1024 * 1024, timeout: 10 * 60 * 1000, stdio: ["ignore", "pipe", "pipe"] }, + ) + } catch (err) { + console.warn(`chunk ${index} attempt ${attempt}: kilo run failed: ${err.message}`) + continue + } + fs.writeFileSync(`${OUT_DIR}/triage-raw-${index}.txt`, raw) + const entries = parseTriageEntries(raw) + if (entries) return entries + 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..d886d1c18ce --- /dev/null +++ b/.github/docs-sync/upsert-pr.mjs @@ -0,0 +1,205 @@ +// 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() + +function shortRef(url) { + return String(url).replace("https://github.com/", "").replace("/pull/", "#") +} + +function changeRow(e) { + return `| ${e.action} | [${shortRef(e.url)}](${e.url}) |` +} + +function skippedRow(e) { + const reason = String(e.reason ?? "").replaceAll("|", "\\|").replaceAll("\n", " ") + return `| [${shortRef(e.url)}](${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 }) { + 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"}** +${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 = process.env.PREP_MODE === "update" ? "update" : "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]) + const changedFiles = git(["diff", "--cached", "--name-only"]).split("\n").filter(Boolean) + git(["commit", "-m", `docs: sync with merged PRs (${date})`]) + + 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") + 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, + }) + + 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"] } }) + } + + 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..a16fc57322a --- /dev/null +++ b/.github/docs-sync/watermark.mjs @@ -0,0 +1,65 @@ +// 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) { + 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) +} + +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..36b1ae32914 --- /dev/null +++ b/.github/workflows/docs-sync.yml @@ -0,0 +1,167 @@ +# 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 || 'kilocode/moonshotai/kimi-k3' }} + EDIT_MODEL: ${{ vars.DOCS_SYNC_EDIT_MODEL || 'kilocode/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_CONFIG_CONTENT: ${{ secrets.DOCS_SYNC_KILO_CONFIG }} + 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_CONFIG_CONTENT: ${{ secrets.DOCS_SYNC_KILO_CONFIG }} + 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_CONFIG_CONTENT: ${{ secrets.DOCS_SYNC_KILO_CONFIG }} + NEXT_PUBLIC_POSTHOG_KEY: ${{ secrets.POSTHOG_API_KEY }} + run: | + set -o pipefail + kilo run -m "$EDIT_MODEL" --dir "$GITHUB_WORKSPACE" \ + -f docs-sync-out/verify.log \ + "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." \ + | 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", From d1f66fe3498a9b298aab14f6a6556f05c01d8d7d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 24 Jul 2026 15:12:40 +0200 Subject: [PATCH 2/7] fix: correct kilo run invocation and auth - message positional must come before flags: --file is multi-value and consumes a trailing message as a file path (File not found) - authenticate via the existing KILO_API_KEY repo secret (the kilo provider reads it natively); drop the DOCS_SYNC_KILO_CONFIG config secret requirement - fix default model IDs: gateway provider id is kilo/, not kilocode/ - include stderr tail in triage/edit failure logs --- .github/docs-sync/edit.mjs | 9 ++++++--- .github/docs-sync/triage.mjs | 11 +++++++---- .github/workflows/docs-sync.yml | 15 +++++++-------- 3 files changed, 20 insertions(+), 15 deletions(-) diff --git a/.github/docs-sync/edit.mjs b/.github/docs-sync/edit.mjs index 1ecc081eae7..fc2ef4845e7 100644 --- a/.github/docs-sync/edit.mjs +++ b/.github/docs-sync/edit.mjs @@ -10,7 +10,7 @@ * warning — its PRs show up in the rolling PR body as skipped, so nothing * fails silently. * - * Env: EDIT_MODEL (provider/model), KILO_CONFIG_CONTENT (set by workflow). + * Env: EDIT_MODEL (provider/model), KILO_API_KEY (set by workflow; read natively by the kilo provider). */ import { execFileSync } from "node:child_process" @@ -56,15 +56,18 @@ Batch specifics for this run: the PRs to handle are in the attached ${batchFile} 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", "-m", model, "--variant", "high", "--dir", process.cwd(), "-f", batchFile, "-f", triageFile, prompt], + ["run", prompt, "-m", model, "--variant", "high", "--dir", process.cwd(), "-f", batchFile, "-f", triageFile], { encoding: "utf8", maxBuffer: 32 * 1024 * 1024, timeout: 25 * 60 * 1000, stdio: ["ignore", "inherit", "inherit"] }, ) if (fs.existsSync(summaryFile)) return true console.warn(`batch ${index} attempt ${attempt}: summary file ${summaryFile} not produced`) } catch (err) { - console.warn(`batch ${index} attempt ${attempt}: kilo run failed: ${err.message}`) + 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`) diff --git a/.github/docs-sync/triage.mjs b/.github/docs-sync/triage.mjs index 7508f9ba004..532ffaa0401 100644 --- a/.github/docs-sync/triage.mjs +++ b/.github/docs-sync/triage.mjs @@ -10,8 +10,8 @@ * "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_CONFIG_CONTENT (CLI auth, set by - * the workflow). Reads the prompt from triage-prompt.md next to this script. + * 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" @@ -38,13 +38,16 @@ function triageChunk(chunk, index) { 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", "-m", model, "--dir", process.cwd(), "-f", chunkFile, prompt], + ["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) { - console.warn(`chunk ${index} attempt ${attempt}: kilo run failed: ${err.message}`) + 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) diff --git a/.github/workflows/docs-sync.yml b/.github/workflows/docs-sync.yml index 36b1ae32914..9eacf14e614 100644 --- a/.github/workflows/docs-sync.yml +++ b/.github/workflows/docs-sync.yml @@ -33,8 +33,8 @@ concurrency: cancel-in-progress: false env: - TRIAGE_MODEL: ${{ vars.DOCS_SYNC_TRIAGE_MODEL || 'kilocode/moonshotai/kimi-k3' }} - EDIT_MODEL: ${{ vars.DOCS_SYNC_EDIT_MODEL || 'kilocode/moonshotai/kimi-k3' }} + TRIAGE_MODEL: ${{ vars.DOCS_SYNC_TRIAGE_MODEL || 'kilo/moonshotai/kimi-k3' }} + EDIT_MODEL: ${{ vars.DOCS_SYNC_EDIT_MODEL || 'kilo/moonshotai/kimi-k3' }} jobs: sync: @@ -75,7 +75,7 @@ jobs: id: triage if: steps.collect.outputs.count != '0' env: - KILO_CONFIG_CONTENT: ${{ secrets.DOCS_SYNC_KILO_CONFIG }} + KILO_API_KEY: ${{ secrets.KILO_API_KEY }} run: node .github/docs-sync/triage.mjs - name: Filter docs-worthy PRs @@ -104,7 +104,7 @@ jobs: - name: Update docs (Kilo CLI, batched) if: (steps.worthy.outputs.count || '0') != '0' && inputs.dry_run != true env: - KILO_CONFIG_CONTENT: ${{ secrets.DOCS_SYNC_KILO_CONFIG }} + KILO_API_KEY: ${{ secrets.KILO_API_KEY }} run: node .github/docs-sync/edit.mjs - name: Verify docs build and tests @@ -122,13 +122,12 @@ jobs: if: steps.verify.outcome == 'failure' continue-on-error: true env: - KILO_CONFIG_CONTENT: ${{ secrets.DOCS_SYNC_KILO_CONFIG }} + KILO_API_KEY: ${{ secrets.KILO_API_KEY }} NEXT_PUBLIC_POSTHOG_KEY: ${{ secrets.POSTHOG_API_KEY }} run: | set -o pipefail - kilo run -m "$EDIT_MODEL" --dir "$GITHUB_WORKSPACE" \ - -f docs-sync-out/verify.log \ - "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." \ + 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 From e37cabb2dd3442079420e019047408827e711460 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 24 Jul 2026 15:19:20 +0200 Subject: [PATCH 3/7] fix: handle kilo run double-printed assistant output kilo run prints the assistant message twice (streaming render + final summary), so stdout can contain the same JSON array back-to-back. Parse the largest valid trailing array instead of slicing first-to-last bracket. Verified against real chunked triage output. --- .github/docs-sync/extract-json.mjs | 31 ++++++++++++++++++++++-------- 1 file changed, 23 insertions(+), 8 deletions(-) diff --git a/.github/docs-sync/extract-json.mjs b/.github/docs-sync/extract-json.mjs index 7dfc052bc73..be8682497cd 100644 --- a/.github/docs-sync/extract-json.mjs +++ b/.github/docs-sync/extract-json.mjs @@ -12,18 +12,33 @@ import { pathToFileURL } from "node:url" /** Returns validated triage entries, or null when extraction fails. */ export function parseTriageEntries(raw) { - const start = raw.indexOf("[") + // `kilo run` prints the assistant message twice (streaming render + final + // summary), so stdout can hold the same array back-to-back. Find the + // largest valid trailing array: try each "[" from the end and keep the + // first candidate that parses. const end = raw.lastIndexOf("]") - if (start < 0 || end <= start) return null + if (end < 0) return null - let parsed - try { - parsed = JSON.parse(raw.slice(start, end + 1)) - } catch { - return null + const starts = [] + for (let i = 0; i <= end; i++) { + if (raw[i] === "[") starts.push(i) } - if (!Array.isArray(parsed)) return null + 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) From 91fc5de4080e398c4741dc9b1d2482477a009ab6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 24 Jul 2026 15:31:27 +0200 Subject: [PATCH 4/7] fix: reviewer-pass robustness fixes - edit.mjs: unambiguous summary file path in the batch prompt and a fallback read when the agent drops the docs-sync-out/ prefix, so real edits never report as skipped - prepare-branch.mjs: use the open auto-docs PR's actual head.ref instead of assuming docs/auto-sync - upsert-pr.mjs: compute the 15-file draft cap on the cumulative PR diff (origin/main...HEAD), not just the latest commit --- .github/docs-sync/edit.mjs | 8 +++++++- .github/docs-sync/prepare-branch.mjs | 26 ++++++++++++++------------ .github/docs-sync/upsert-pr.mjs | 5 ++++- 3 files changed, 25 insertions(+), 14 deletions(-) diff --git a/.github/docs-sync/edit.mjs b/.github/docs-sync/edit.mjs index fc2ef4845e7..786a2eaa4e6 100644 --- a/.github/docs-sync/edit.mjs +++ b/.github/docs-sync/edit.mjs @@ -52,7 +52,7 @@ function editBatch(batch, index) { const prompt = `${basePrompt} -Batch specifics for this run: the PRs to handle are in the attached ${batchFile} (full details) and ${triageFile} (triage verdicts). Write your per-PR results to ${summaryFile} at the repository root in the summary JSON format described above. Handle ONLY the PRs in these batch files.` +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 { @@ -64,6 +64,12 @@ Batch specifics for this run: the PRs to handle are in the attached ${batchFile} { encoding: "utf8", maxBuffer: 32 * 1024 * 1024, timeout: 25 * 60 * 1000, stdio: ["ignore", "inherit", "inherit"] }, ) 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") diff --git a/.github/docs-sync/prepare-branch.mjs b/.github/docs-sync/prepare-branch.mjs index 2c0d11e9659..0731295b349 100644 --- a/.github/docs-sync/prepare-branch.mjs +++ b/.github/docs-sync/prepare-branch.mjs @@ -2,7 +2,7 @@ /** * Prepares the rolling docs-sync branch before the edit pass: - * - an open auto-docs PR exists -> check out its branch and merge + * - 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) * @@ -10,9 +10,9 @@ */ import { execFileSync } from "node:child_process" -import { appendOutput, repo, searchIssues } from "./lib.mjs" +import { api, appendOutput, repo, searchIssues } from "./lib.mjs" -export const BRANCH = "docs/auto-sync" +export const DEFAULT_BRANCH = "docs/auto-sync" const git = (args) => execFileSync("git", args, { stdio: ["ignore", "pipe", "inherit"] }).toString().trim() @@ -20,34 +20,36 @@ const prs = await searchIssues(`repo:${repo()} is:pr is:open label:auto-docs sor let mode = "fresh" let prNumber = "" +let branch = DEFAULT_BRANCH if (prs.length > 0) { - const pr = prs[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]) + 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; resetting branch to origin/main.`) + console.warn(`merge of origin/main into ${branch} conflicted; resetting branch to origin/main.`) console.warn("Unmerged work on the previous branch is re-derived from the watermark window.") git(["merge", "--abort"]) - git(["checkout", "-B", BRANCH, "origin/main"]) + git(["checkout", "-B", branch, "origin/main"]) mode = "fresh" } } 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}`]) + git(["fetch", "origin", `+refs/heads/${branch}:refs/remotes/origin/${branch}`]) } catch { // branch does not exist on origin yet — fine } - git(["checkout", "-B", BRANCH, "origin/main"]) + git(["checkout", "-B", branch, "origin/main"]) } -appendOutput("branch", BRANCH) +appendOutput("branch", branch) appendOutput("mode", mode) appendOutput("pr_number", prNumber) -console.log(`branch ${BRANCH} ready (mode=${mode}, pr=${prNumber || "none"})`) +console.log(`branch ${branch} ready (mode=${mode}, pr=${prNumber || "none"})`) diff --git a/.github/docs-sync/upsert-pr.mjs b/.github/docs-sync/upsert-pr.mjs index d886d1c18ce..b7b3ccb66e6 100644 --- a/.github/docs-sync/upsert-pr.mjs +++ b/.github/docs-sync/upsert-pr.mjs @@ -117,9 +117,12 @@ async function main() { git(["config", "user.name", "github-actions[bot]"]) git(["config", "user.email", "41898282+github-actions[bot]@users.noreply.github.com"]) git(["add", DOCS_PATH]) - const changedFiles = git(["diff", "--cached", "--name-only"]).split("\n").filter(Boolean) 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") From 4c68610a5967739d103d6257fe7b5d4ff1bffcfb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 24 Jul 2026 15:40:16 +0200 Subject: [PATCH 5/7] fix: address Kilobot review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Security: - sanitize HTML-comment sequences out of agent-generated PR body values so a crafted value cannot forge section markers or the watermark - draft any PR whose diff touches non-content files in packages/kilo-docs (outside pages/ and lib/nav/) — build-executable changes force human review before merge - on merge conflict, keep the conflicted rolling branch untouched (preserving human commits) and continue on a fresh dated branch that links the old PR Resilience: - retry GitHub API calls on network errors and 5xx, not just 403 rate limits - isolate per-PR collect failures instead of aborting the run - trust watermark markers only on bot-authored PRs and clamp future dates loudly - validate chunk triage entries belong to their chunk before the shared dedupe - use changed_files for files_total and skip docs-only classification on truncated (300+) file lists - pipe stderr in the edit pass so failure warnings carry the real CLI error --- .github/docs-sync/collect.mjs | 24 +++++++++++---- .github/docs-sync/edit.mjs | 4 ++- .github/docs-sync/extract-json.mjs | 6 ++-- .github/docs-sync/lib.mjs | 44 ++++++++++++++++++++-------- .github/docs-sync/prepare-branch.mjs | 12 ++++++-- .github/docs-sync/triage.mjs | 11 ++++++- .github/docs-sync/upsert-pr.mjs | 42 +++++++++++++++++++++----- .github/docs-sync/watermark.mjs | 10 +++++++ 8 files changed, 119 insertions(+), 34 deletions(-) diff --git a/.github/docs-sync/collect.mjs b/.github/docs-sync/collect.mjs index 686a57cb548..80806eda03c 100644 --- a/.github/docs-sync/collect.mjs +++ b/.github/docs-sync/collect.mjs @@ -42,7 +42,7 @@ const since = argSince() console.log(`collecting PRs merged since ${since.toISOString()}`) const digest = [] -const dropped = { bot: 0, label: 0, title: 0, docs_only: 0 } +const dropped = { bot: 0, label: 0, title: 0, docs_only: 0, fetch_error: 0 } for (const fullRepo of SOURCE_REPOS) { const prs = await mergedPrs(fullRepo, since) @@ -64,9 +64,21 @@ for (const fullRepo of SOURCE_REPOS) { } const number = item.number - const pr = await api(`/repos/${fullRepo}/pulls/${number}`) - const files = await listPrFiles(fullRepo, number) - if (files.length > 0 && files.every((f) => DOCS_ONLY_PATH.test(f.filename))) { + 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 } @@ -92,7 +104,7 @@ for (const fullRepo of SOURCE_REPOS) { 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: files.length, + files_total: pr.changed_files ?? files.length, patch_excerpt: patch, }) } @@ -120,7 +132,7 @@ appendSummary( "", `- window: since \`${since.toISOString()}\``, `- kept: **${digest.length}** PRs`, - `- dropped: ${dropped.bot} bot, ${dropped.label} auto-docs, ${dropped.title} title filter, ${dropped.docs_only} docs-only`, + `- dropped: ${dropped.bot} bot, ${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.mjs b/.github/docs-sync/edit.mjs index 786a2eaa4e6..52943de31ab 100644 --- a/.github/docs-sync/edit.mjs +++ b/.github/docs-sync/edit.mjs @@ -61,7 +61,9 @@ Batch specifics for this run: the PRs to handle are in the attached ${batchFile} execFileSync( "kilo", ["run", prompt, "-m", model, "--variant", "high", "--dir", process.cwd(), "-f", batchFile, "-f", triageFile], - { encoding: "utf8", maxBuffer: 32 * 1024 * 1024, timeout: 25 * 60 * 1000, stdio: ["ignore", "inherit", "inherit"] }, + // 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. diff --git a/.github/docs-sync/extract-json.mjs b/.github/docs-sync/extract-json.mjs index be8682497cd..c8cdf04ff12 100644 --- a/.github/docs-sync/extract-json.mjs +++ b/.github/docs-sync/extract-json.mjs @@ -13,9 +13,9 @@ 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. Find the - // largest valid trailing array: try each "[" from the end and keep the - // first candidate that parses. + // 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 diff --git a/.github/docs-sync/lib.mjs b/.github/docs-sync/lib.mjs index a88d22fd948..56dfb7b9226 100644 --- a/.github/docs-sync/lib.mjs +++ b/.github/docs-sync/lib.mjs @@ -27,26 +27,44 @@ 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++) { - const 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), - }) + 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 && attempt < MAX_RETRIES) { + if (res.status === 403) { const text = await res.text() - if (text.includes("rate limit")) { + 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 } - throw new Error(`${method} ${path} -> 403: ${text}`) + 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) { diff --git a/.github/docs-sync/prepare-branch.mjs b/.github/docs-sync/prepare-branch.mjs index 0731295b349..bd250323c48 100644 --- a/.github/docs-sync/prepare-branch.mjs +++ b/.github/docs-sync/prepare-branch.mjs @@ -32,11 +32,17 @@ if (prs.length > 0) { git(["merge", "origin/main", "--no-edit"]) mode = "update" } catch { - console.warn(`merge of origin/main into ${branch} conflicted; resetting branch to origin/main.`) - console.warn("Unmerged work on the previous branch is re-derived from the watermark window.") + 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 { + // dated branch does not exist on origin yet — fine + } git(["checkout", "-B", branch, "origin/main"]) - mode = "fresh" + mode = "conflict" } } else { // Keep the remote-tracking ref current so the later --force-with-lease diff --git a/.github/docs-sync/triage.mjs b/.github/docs-sync/triage.mjs index 532ffaa0401..034a7ddcbda 100644 --- a/.github/docs-sync/triage.mjs +++ b/.github/docs-sync/triage.mjs @@ -52,7 +52,16 @@ function triageChunk(chunk, index) { } fs.writeFileSync(`${OUT_DIR}/triage-raw-${index}.txt`, raw) const entries = parseTriageEntries(raw) - if (entries) return entries + 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`) } diff --git a/.github/docs-sync/upsert-pr.mjs b/.github/docs-sync/upsert-pr.mjs index b7b3ccb66e6..2487f7c5b52 100644 --- a/.github/docs-sync/upsert-pr.mjs +++ b/.github/docs-sync/upsert-pr.mjs @@ -22,17 +22,24 @@ 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 String(url).replace("https://github.com/", "").replace("/pull/", "#") + return clean(url).replace("https://github.com/", "").replace("/pull/", "#") } function changeRow(e) { - return `| ${e.action} | [${shortRef(e.url)}](${e.url}) |` + return `| ${clean(e.action)} | [${shortRef(e.url)}](${clean(e.url)}) |` } function skippedRow(e) { - const reason = String(e.reason ?? "").replaceAll("|", "\\|").replaceAll("\n", " ") - return `| [${shortRef(e.url)}](${e.url}) | ${reason} |` + const reason = clean(e.reason).replaceAll("|", "\\|").replaceAll("\n", " ") + return `| [${shortRef(e.url)}](${clean(e.url)}) | ${reason} |` } export function extractSectionRows(body, name) { @@ -51,14 +58,14 @@ function section(name, header, rows) { return `\n${body}\n` } -export function renderBody({ date, since, through, changesRows, skippedRows, verified, draftReasons }) { +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"}** -${draftReasons.length > 0 ? `- Draft because: ${draftReasons.join("; ")}\n` : ""} +${note ? `- ${note}\n` : ""}${draftReasons.length > 0 ? `- Draft because: ${draftReasons.join("; ")}\n` : ""} ### Changes ${section("changes", "| Docs change | Source |", changesRows)} @@ -98,7 +105,7 @@ async function main() { const through = process.env.PROCESSED_THROUGH ?? new Date().toISOString() const since = process.env.SINCE ?? "unknown" - const mode = process.env.PREP_MODE === "update" ? "update" : "fresh" + 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) @@ -126,6 +133,15 @@ async function main() { 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) { + draftReasons.push(`touches non-content files outside pages/ and lib/nav/: ${nonContent.slice(0, 5).join(", ")}`) + } const draft = draftReasons.length > 0 git(mode === "update" ? ["push", "origin", `HEAD:${BRANCH}`] : ["push", "--force-with-lease", "origin", `HEAD:${BRANCH}`]) @@ -152,6 +168,10 @@ async function main() { skippedRows: mergeRows(oldSkipped, skippedNew), verified, draftReasons, + note: + mode === "conflict" && existingPr + ? `Continues from #${existingPr}, whose branch conflicted with \`main\` (its commits are preserved there).` + : "", }) try { @@ -192,6 +212,14 @@ async function main() { 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) diff --git a/.github/docs-sync/watermark.mjs b/.github/docs-sync/watermark.mjs index a16fc57322a..cc4c9690168 100644 --- a/.github/docs-sync/watermark.mjs +++ b/.github/docs-sync/watermark.mjs @@ -29,6 +29,9 @@ async function findWatermark() { 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()}`) @@ -54,6 +57,13 @@ if (input) { (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`) From 8b7da4d06be19502f468c2528370dd026067f7b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 24 Jul 2026 16:22:08 +0200 Subject: [PATCH 6/7] fix: address second Kilobot review round - escape pipe characters in changeRow actions (same as skippedRow) - sanitize agent-chosen file paths before they land in draftReasons and the PR body (residual marker-forgery path via filenames) - log expected fetch misses in prepare-branch instead of silent catches --- .github/docs-sync/prepare-branch.mjs | 4 ++-- .github/docs-sync/upsert-pr.mjs | 9 +++++++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/.github/docs-sync/prepare-branch.mjs b/.github/docs-sync/prepare-branch.mjs index bd250323c48..2b57932c442 100644 --- a/.github/docs-sync/prepare-branch.mjs +++ b/.github/docs-sync/prepare-branch.mjs @@ -39,7 +39,7 @@ if (prs.length > 0) { try { git(["fetch", "origin", `+refs/heads/${branch}:refs/remotes/origin/${branch}`]) } catch { - // dated branch does not exist on origin yet — fine + console.log(`dated branch ${branch} does not exist on origin yet; will create it on push`) } git(["checkout", "-B", branch, "origin/main"]) mode = "conflict" @@ -50,7 +50,7 @@ if (prs.length > 0) { try { git(["fetch", "origin", `+refs/heads/${branch}:refs/remotes/origin/${branch}`]) } catch { - // branch does not exist on origin yet — fine + console.log(`branch ${branch} does not exist on origin yet; will create it on push`) } git(["checkout", "-B", branch, "origin/main"]) } diff --git a/.github/docs-sync/upsert-pr.mjs b/.github/docs-sync/upsert-pr.mjs index 2487f7c5b52..e39551f307c 100644 --- a/.github/docs-sync/upsert-pr.mjs +++ b/.github/docs-sync/upsert-pr.mjs @@ -34,7 +34,7 @@ function shortRef(url) { } function changeRow(e) { - return `| ${clean(e.action)} | [${shortRef(e.url)}](${clean(e.url)}) |` + return `| ${clean(e.action).replaceAll("|", "\\|")} | [${shortRef(e.url)}](${clean(e.url)}) |` } function skippedRow(e) { @@ -140,7 +140,12 @@ async function main() { (f) => !f.startsWith("packages/kilo-docs/pages/") && !f.startsWith("packages/kilo-docs/lib/nav/"), ) if (nonContent.length > 0) { - draftReasons.push(`touches non-content files outside pages/ and lib/nav/: ${nonContent.slice(0, 5).join(", ")}`) + // 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 From 99b04972d0d6385564543026d22bf87420590ea7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 24 Jul 2026 17:26:22 +0200 Subject: [PATCH 7/7] feat: keep bot-authored PRs in the docs-sync digest Release and dependency bots ship user-facing changes (e.g. JetBrains release PRs from kilo-maintainer[bot]). The auto-docs label check and docs-only path filter remain as the loop guards. --- .github/docs-sync/collect.mjs | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/.github/docs-sync/collect.mjs b/.github/docs-sync/collect.mjs index 80806eda03c..51be1a3d8e1 100644 --- a/.github/docs-sync/collect.mjs +++ b/.github/docs-sync/collect.mjs @@ -6,10 +6,12 @@ * triage pass. * * Pre-filter drops (triage never sees these): - * - bot-authored PRs (includes this bot's own rolling PRs) - * - PRs labeled auto-docs + * - 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" @@ -42,7 +44,7 @@ const since = argSince() console.log(`collecting PRs merged since ${since.toISOString()}`) const digest = [] -const dropped = { bot: 0, label: 0, title: 0, docs_only: 0, fetch_error: 0 } +const dropped = { label: 0, title: 0, docs_only: 0, fetch_error: 0 } for (const fullRepo of SOURCE_REPOS) { const prs = await mergedPrs(fullRepo, since) @@ -50,10 +52,6 @@ for (const fullRepo of SOURCE_REPOS) { for (const item of prs) { const author = item.user?.login ?? "" - if (author.endsWith("[bot]")) { - dropped.bot++ - continue - } if ((item.labels ?? []).some((l) => l.name === "auto-docs")) { dropped.label++ continue @@ -132,7 +130,7 @@ appendSummary( "", `- window: since \`${since.toISOString()}\``, `- kept: **${digest.length}** PRs`, - `- dropped: ${dropped.bot} bot, ${dropped.label} auto-docs, ${dropped.title} title filter, ${dropped.docs_only} docs-only, ${dropped.fetch_error} fetch errors`, + `- 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"),