-
Notifications
You must be signed in to change notification settings - Fork 3.1k
feat: daily docs-sync bot keeping kilo-docs in sync with merged PRs #12512
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
405ed74
feat: daily docs-sync bot workflow (Kilo CLI)
iscekic d1f66fe
fix: correct kilo run invocation and auth
iscekic e37cabb
fix: handle kilo run double-printed assistant output
iscekic 91fc5de
fix: reviewer-pass robustness fixes
iscekic 4c68610
fix: address Kilobot review findings
iscekic 8b7da4d
fix: address second Kilobot review round
iscekic 99b0497
feat: keep bot-authored PRs in the docs-sync digest
iscekic File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 <ISO date>") | ||
| } | ||
| 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"), | ||
| ) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 <path>`, `created <path>`, 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"}] |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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`) | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,76 @@ | ||
| // kilocode_change - new file | ||
|
|
||
| /** | ||
| * Extracts and validates the triage JSON array from raw LLM stdout. | ||
| * Usage: extract-json.mjs <raw-input-file> <output-file> | ||
| * 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 | ||
|
iscekic marked this conversation as resolved.
|
||
| // 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 <raw-input-file> <output-file>") | ||
| 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() | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 <digest-full.json> <triage.json> <output.json> | ||
| * 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 <digest-full.json> <triage.json> <output.json>") | ||
| 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`) |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.