Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
137 changes: 137 additions & 0 deletions .github/docs-sync/collect.mjs
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"),
)
24 changes: 24 additions & 0 deletions .github/docs-sync/edit-prompt.md
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"}]
122 changes: 122 additions & 0 deletions .github/docs-sync/edit.mjs
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")
Comment thread
iscekic marked this conversation as resolved.
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`)
76 changes: 76 additions & 0 deletions .github/docs-sync/extract-json.mjs
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
Comment thread
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()
}
24 changes: 24 additions & 0 deletions .github/docs-sync/filter-worthy.mjs
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`)
Loading
Loading