mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-30 17:14:40 +08:00
feat: daily docs-sync bot keeping kilo-docs in sync with merged PRs (#12512)
* 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. * 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 * 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. * 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 * fix: address Kilobot review findings 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 * 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 * 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.
This commit is contained in:
@@ -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"),
|
||||
)
|
||||
@@ -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"}]
|
||||
@@ -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`)
|
||||
@@ -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
|
||||
// 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()
|
||||
}
|
||||
@@ -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`)
|
||||
@@ -0,0 +1,113 @@
|
||||
// kilocode_change - new file
|
||||
|
||||
/**
|
||||
* Shared helpers for the docs-sync bot scripts. Dependency-free (Node 20+
|
||||
* global fetch) so the workflow does not rely on runner images shipping the
|
||||
* gh CLI.
|
||||
*/
|
||||
|
||||
import fs from "node:fs"
|
||||
|
||||
const API = "https://api.github.com"
|
||||
const MAX_RETRIES = 3
|
||||
|
||||
export function token() {
|
||||
const t = process.env.GH_TOKEN || process.env.GITHUB_TOKEN
|
||||
if (!t) throw new Error("GH_TOKEN (or GITHUB_TOKEN) is required")
|
||||
return t
|
||||
}
|
||||
|
||||
export function repo() {
|
||||
const r = process.env.GITHUB_REPOSITORY
|
||||
if (!r) throw new Error("GITHUB_REPOSITORY is required")
|
||||
return r
|
||||
}
|
||||
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
||||
|
||||
export async function api(path, { method = "GET", body } = {}) {
|
||||
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
|
||||
let res
|
||||
try {
|
||||
res = await fetch(`${API}${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
authorization: `Bearer ${token()}`,
|
||||
accept: "application/vnd.github+json",
|
||||
"x-github-api-version": "2022-11-28",
|
||||
"user-agent": "kilo-docs-sync-bot",
|
||||
},
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
})
|
||||
} catch (err) {
|
||||
if (attempt < MAX_RETRIES) {
|
||||
console.warn(`network error (${err.message}), retrying in ${5 * attempt}s`)
|
||||
await sleep(5000 * attempt)
|
||||
continue
|
||||
}
|
||||
throw err
|
||||
}
|
||||
|
||||
if (res.status === 403) {
|
||||
const text = await res.text()
|
||||
if (text.includes("rate limit") && attempt < MAX_RETRIES) {
|
||||
const retryAfter = Number(res.headers.get("retry-after")) || 30
|
||||
console.warn(`rate limited, retrying in ${retryAfter}s`)
|
||||
await sleep(retryAfter * 1000)
|
||||
continue
|
||||
}
|
||||
const err = new Error(`${method} ${path} -> 403: ${text}`)
|
||||
err.status = 403
|
||||
throw err
|
||||
}
|
||||
|
||||
if (res.status >= 500 && attempt < MAX_RETRIES) {
|
||||
console.warn(`${method} ${path} -> ${res.status}, retrying in ${5 * attempt}s`)
|
||||
await sleep(5000 * attempt)
|
||||
continue
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text()
|
||||
const err = new Error(`${method} ${path} -> ${res.status}: ${text}`)
|
||||
err.status = res.status
|
||||
throw err
|
||||
}
|
||||
|
||||
if (res.status === 204) return null
|
||||
return res.json()
|
||||
}
|
||||
throw new Error(`${method} ${path}: exhausted retries`)
|
||||
}
|
||||
|
||||
/** Paginated search/issues. Caps at `maxPages` * 100 results. */
|
||||
export async function searchIssues(query, { maxPages = 5 } = {}) {
|
||||
const items = []
|
||||
for (let page = 1; page <= maxPages; page++) {
|
||||
const data = await api(`/search/issues?q=${encodeURIComponent(query)}&per_page=100&page=${page}`)
|
||||
items.push(...(data.items ?? []))
|
||||
if ((data.items ?? []).length < 100) break
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
export async function listPrFiles(fullRepo, number, { maxPages = 3 } = {}) {
|
||||
const files = []
|
||||
for (let page = 1; page <= maxPages; page++) {
|
||||
const batch = await api(`/repos/${fullRepo}/pulls/${number}/files?per_page=100&page=${page}`)
|
||||
files.push(...batch)
|
||||
if (batch.length < 100) break
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
export function appendOutput(name, value) {
|
||||
const out = process.env.GITHUB_OUTPUT
|
||||
if (out) fs.appendFileSync(out, `${name}=${value}\n`)
|
||||
console.log(`output ${name}=${value}`)
|
||||
}
|
||||
|
||||
export function appendSummary(markdown) {
|
||||
const summary = process.env.GITHUB_STEP_SUMMARY
|
||||
if (summary) fs.appendFileSync(summary, markdown + "\n")
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
// kilocode_change - new file
|
||||
|
||||
/**
|
||||
* Prepares the rolling docs-sync branch before the edit pass:
|
||||
* - an open auto-docs PR exists -> check out its head branch and merge
|
||||
* origin/main (preserves any human commits on the branch)
|
||||
* - otherwise -> fresh branch from origin/main (bot force-pushes later)
|
||||
*
|
||||
* Outputs: branch, mode (update|fresh), pr_number (empty when fresh).
|
||||
*/
|
||||
|
||||
import { execFileSync } from "node:child_process"
|
||||
import { api, appendOutput, repo, searchIssues } from "./lib.mjs"
|
||||
|
||||
export const DEFAULT_BRANCH = "docs/auto-sync"
|
||||
|
||||
const git = (args) => execFileSync("git", args, { stdio: ["ignore", "pipe", "inherit"] }).toString().trim()
|
||||
|
||||
const prs = await searchIssues(`repo:${repo()} is:pr is:open label:auto-docs sort:created-desc`, { maxPages: 1 })
|
||||
|
||||
let mode = "fresh"
|
||||
let prNumber = ""
|
||||
let branch = DEFAULT_BRANCH
|
||||
|
||||
if (prs.length > 0) {
|
||||
const pr = await api(`/repos/${repo()}/pulls/${prs[0].number}`)
|
||||
branch = pr.head?.ref ?? DEFAULT_BRANCH
|
||||
prNumber = String(pr.number)
|
||||
git(["fetch", "origin", "main", branch])
|
||||
git(["checkout", branch])
|
||||
try {
|
||||
git(["merge", "origin/main", "--no-edit"])
|
||||
mode = "update"
|
||||
} catch {
|
||||
console.warn(`merge of origin/main into ${branch} conflicted.`)
|
||||
console.warn("Leaving the conflicted branch untouched so human commits are preserved; continuing on a fresh dated branch.")
|
||||
git(["merge", "--abort"])
|
||||
branch = `${DEFAULT_BRANCH}-${new Date().toISOString().slice(0, 10)}`
|
||||
try {
|
||||
git(["fetch", "origin", `+refs/heads/${branch}:refs/remotes/origin/${branch}`])
|
||||
} catch {
|
||||
console.log(`dated branch ${branch} does not exist on origin yet; will create it on push`)
|
||||
}
|
||||
git(["checkout", "-B", branch, "origin/main"])
|
||||
mode = "conflict"
|
||||
}
|
||||
} else {
|
||||
// Keep the remote-tracking ref current so the later --force-with-lease
|
||||
// push (stale branch left over from a merged/closed PR) is safe.
|
||||
try {
|
||||
git(["fetch", "origin", `+refs/heads/${branch}:refs/remotes/origin/${branch}`])
|
||||
} catch {
|
||||
console.log(`branch ${branch} does not exist on origin yet; will create it on push`)
|
||||
}
|
||||
git(["checkout", "-B", branch, "origin/main"])
|
||||
}
|
||||
|
||||
appendOutput("branch", branch)
|
||||
appendOutput("mode", mode)
|
||||
appendOutput("pr_number", prNumber)
|
||||
console.log(`branch ${branch} ready (mode=${mode}, pr=${prNumber || "none"})`)
|
||||
@@ -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"}]
|
||||
@@ -0,0 +1,111 @@
|
||||
// kilocode_change - new file
|
||||
|
||||
/**
|
||||
* Runs the LLM triage pass over docs-sync-out/digest.json in chunks.
|
||||
*
|
||||
* A daily window holds ~30-50 PRs; a replay can hold several hundred. A
|
||||
* single triage call over that volume truncates its JSON output, so the
|
||||
* digest is split into chunks of CHUNK_SIZE and each chunk is triaged with
|
||||
* its own `kilo run` call. A chunk that fails twice is degraded to
|
||||
* "unclassified" entries (docs_worthy=false) instead of failing the run —
|
||||
* the PR body then shows those PRs as skipped, visible to reviewers.
|
||||
*
|
||||
* Env: TRIAGE_MODEL (provider/model), KILO_API_KEY (gateway auth, set by the workflow;
|
||||
* the kilo provider reads it natively). Reads the prompt from triage-prompt.md next to this script.
|
||||
*/
|
||||
|
||||
import { execFileSync } from "node:child_process"
|
||||
import fs from "node:fs"
|
||||
import path from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { parseTriageEntries } from "./extract-json.mjs"
|
||||
|
||||
const CHUNK_SIZE = 25
|
||||
const ATTEMPTS = 2
|
||||
const OUT_DIR = "docs-sync-out"
|
||||
|
||||
const HERE = path.dirname(fileURLToPath(import.meta.url))
|
||||
const prompt = fs.readFileSync(path.join(HERE, "triage-prompt.md"), "utf8")
|
||||
const model = process.env.TRIAGE_MODEL
|
||||
if (!model) throw new Error("TRIAGE_MODEL is required")
|
||||
|
||||
const digest = JSON.parse(fs.readFileSync(`${OUT_DIR}/digest.json`, "utf8"))
|
||||
|
||||
function triageChunk(chunk, index) {
|
||||
const chunkFile = `${OUT_DIR}/triage-chunk-${index}.json`
|
||||
fs.writeFileSync(chunkFile, JSON.stringify(chunk, null, 2))
|
||||
|
||||
for (let attempt = 1; attempt <= ATTEMPTS; attempt++) {
|
||||
let raw
|
||||
try {
|
||||
// Message positional first: --file is multi-value and would otherwise
|
||||
// consume a trailing message as a file path ("File not found").
|
||||
raw = execFileSync(
|
||||
"kilo",
|
||||
["run", prompt, "-m", model, "--dir", process.cwd(), "-f", chunkFile],
|
||||
{ encoding: "utf8", maxBuffer: 32 * 1024 * 1024, timeout: 10 * 60 * 1000, stdio: ["ignore", "pipe", "pipe"] },
|
||||
)
|
||||
} catch (err) {
|
||||
const stderr = String(err.stderr ?? "").trim().split("\n").slice(-5).join("\n")
|
||||
console.warn(`chunk ${index} attempt ${attempt}: kilo run failed: ${stderr || err.message}`)
|
||||
continue
|
||||
}
|
||||
fs.writeFileSync(`${OUT_DIR}/triage-raw-${index}.txt`, raw)
|
||||
const entries = parseTriageEntries(raw)
|
||||
if (entries) {
|
||||
// An entry for a PR outside this chunk must not win the shared dedupe
|
||||
// against the chunk that actually owns it — drop foreign entries.
|
||||
const allowed = new Set(chunk.map((d) => d.url))
|
||||
const owned = entries.filter((e) => allowed.has(e.url))
|
||||
if (owned.length !== entries.length) {
|
||||
console.warn(`chunk ${index}: dropped ${entries.length - owned.length} entries for PRs outside the chunk`)
|
||||
}
|
||||
if (owned.length > 0) return owned
|
||||
}
|
||||
console.warn(`chunk ${index} attempt ${attempt}: no valid JSON in output`)
|
||||
}
|
||||
|
||||
console.warn(`::warning::chunk ${index} failed triage after ${ATTEMPTS} attempts; marking ${chunk.length} PRs unclassified`)
|
||||
return chunk.map((d) => ({
|
||||
pr: d.number,
|
||||
url: d.url,
|
||||
docs_worthy: false,
|
||||
reason: "triage failed to classify this PR",
|
||||
target_sections: [],
|
||||
priority: "medium",
|
||||
}))
|
||||
}
|
||||
|
||||
const chunks = []
|
||||
for (let i = 0; i < digest.length; i += CHUNK_SIZE) {
|
||||
chunks.push(digest.slice(i, i + CHUNK_SIZE))
|
||||
}
|
||||
console.log(`triaging ${digest.length} PRs in ${chunks.length} chunks of up to ${CHUNK_SIZE}`)
|
||||
|
||||
const merged = []
|
||||
const seen = new Set()
|
||||
for (let i = 0; i < chunks.length; i++) {
|
||||
for (const e of triageChunk(chunks[i], i)) {
|
||||
if (seen.has(e.url)) continue
|
||||
seen.add(e.url)
|
||||
merged.push(e)
|
||||
}
|
||||
}
|
||||
|
||||
// Coverage: every digest PR gets a triage entry so the PR body's skipped
|
||||
// table is complete. Unclassified defaults to not-docs-worthy (conservative).
|
||||
for (const d of digest) {
|
||||
if (seen.has(d.url)) continue
|
||||
merged.push({
|
||||
pr: d.number,
|
||||
url: d.url,
|
||||
docs_worthy: false,
|
||||
reason: "not classified by triage",
|
||||
target_sections: [],
|
||||
priority: "medium",
|
||||
})
|
||||
}
|
||||
|
||||
fs.writeFileSync(`${OUT_DIR}/triage.json`, JSON.stringify(merged, null, 2))
|
||||
const worthy = merged.filter((e) => e.docs_worthy).length
|
||||
console.log(`triage complete: ${merged.length} entries, ${worthy} docs-worthy`)
|
||||
@@ -0,0 +1,241 @@
|
||||
// kilocode_change - new file
|
||||
|
||||
/**
|
||||
* Commits the agent's packages/kilo-docs changes, pushes the rolling branch,
|
||||
* and creates or updates the rolling auto-docs PR.
|
||||
*
|
||||
* No-op when the agent produced no docs changes. PRs become drafts when the
|
||||
* diff exceeds the file cap or verification failed. The PR body carries
|
||||
* marker-delimited sections so later runs can append rows, plus a
|
||||
* machine-readable processed-through watermark.
|
||||
*/
|
||||
|
||||
import { execFileSync } from "node:child_process"
|
||||
import fs from "node:fs"
|
||||
import { pathToFileURL } from "node:url"
|
||||
|
||||
const BRANCH = process.env.BRANCH || "docs/auto-sync"
|
||||
const FILE_CAP = 15
|
||||
const ROW_CAP = 150
|
||||
const SUMMARY_FILE = ".docs-sync-summary.json"
|
||||
const DOCS_PATH = "packages/kilo-docs"
|
||||
|
||||
const git = (args) => execFileSync("git", args, { stdio: ["ignore", "pipe", "inherit"] }).toString().trim()
|
||||
|
||||
// Agent-generated strings land in the PR body next to machine-read markers.
|
||||
// Strip HTML-comment sequences so a crafted/adversarial value cannot forge
|
||||
// section boundaries or the processed-through watermark.
|
||||
function clean(value) {
|
||||
return String(value ?? "").replaceAll("<!--", "").replaceAll("-->", "")
|
||||
}
|
||||
|
||||
function shortRef(url) {
|
||||
return clean(url).replace("https://github.com/", "").replace("/pull/", "#")
|
||||
}
|
||||
|
||||
function changeRow(e) {
|
||||
return `| ${clean(e.action).replaceAll("|", "\\|")} | [${shortRef(e.url)}](${clean(e.url)}) |`
|
||||
}
|
||||
|
||||
function skippedRow(e) {
|
||||
const reason = clean(e.reason).replaceAll("|", "\\|").replaceAll("\n", " ")
|
||||
return `| [${shortRef(e.url)}](${clean(e.url)}) | ${reason} |`
|
||||
}
|
||||
|
||||
export function extractSectionRows(body, name) {
|
||||
const m = String(body ?? "").match(
|
||||
new RegExp(`<!--\\s*docs-sync:${name}:start\\s*-->([\\s\\S]*?)<!--\\s*docs-sync:${name}:end\\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 `<!-- docs-sync:${name}:start -->\n${body}\n<!-- docs-sync:${name}:end -->`
|
||||
}
|
||||
|
||||
export function renderBody({ date, since, through, changesRows, skippedRows, verified, draftReasons, note }) {
|
||||
return `## Automated docs sync — ${date}
|
||||
|
||||
This PR keeps kilo.ai/docs in sync with features merged to [Kilo-Org/cloud](https://github.com/Kilo-Org/cloud) and [Kilo-Org/kilocode](https://github.com/Kilo-Org/kilocode). Every change below links to the merged PR it documents.
|
||||
|
||||
- Window: \`${since}\` → \`${through}\`
|
||||
- Verification (docs build + tests): **${verified ? "passing" : "FAILING — needs a human look"}**
|
||||
${note ? `- ${note}\n` : ""}${draftReasons.length > 0 ? `- Draft because: ${draftReasons.join("; ")}\n` : ""}
|
||||
### Changes
|
||||
|
||||
${section("changes", "| Docs change | Source |", changesRows)}
|
||||
|
||||
### Considered, no docs change needed
|
||||
|
||||
${section("skipped", "| PR | Reason |", skippedRows)}
|
||||
|
||||
---
|
||||
|
||||
(bot) Generated by the docs-sync workflow. Humans review and merge; while this PR stays open, the next daily run appends new changes here. Branch: \`${BRANCH}\`.
|
||||
<!-- docs-sync: processed-through ${through} -->
|
||||
`
|
||||
}
|
||||
|
||||
function mergeRows(oldRows, newRows) {
|
||||
const seen = new Set()
|
||||
const out = []
|
||||
for (const row of [...oldRows, ...newRows]) {
|
||||
if (seen.has(row)) continue
|
||||
seen.add(row)
|
||||
out.push(row)
|
||||
}
|
||||
return out.slice(-ROW_CAP)
|
||||
}
|
||||
|
||||
function readJson(path, fallback) {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(path, "utf8"))
|
||||
} catch {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const { api, appendOutput, appendSummary, repo } = await import("./lib.mjs")
|
||||
|
||||
const through = process.env.PROCESSED_THROUGH ?? new Date().toISOString()
|
||||
const since = process.env.SINCE ?? "unknown"
|
||||
const mode = ["update", "conflict"].includes(process.env.PREP_MODE) ? process.env.PREP_MODE : "fresh"
|
||||
const existingPr = process.env.PR_NUMBER || ""
|
||||
const verified = process.env.VERIFIED === "true"
|
||||
const date = through.slice(0, 10)
|
||||
|
||||
// The agent's run summary is consumed here and never committed.
|
||||
const agentSummary = readJson(SUMMARY_FILE, [])
|
||||
fs.rmSync(SUMMARY_FILE, { force: true })
|
||||
const triage = readJson("docs-sync-out/triage.json", [])
|
||||
|
||||
if (git(["status", "--porcelain", "--", DOCS_PATH]) === "") {
|
||||
console.log("no packages/kilo-docs changes produced; nothing to commit")
|
||||
appendSummary("### docs-sync: no docs changes\n\nThe agent found nothing worth documenting in this window.")
|
||||
return
|
||||
}
|
||||
|
||||
git(["config", "user.name", "github-actions[bot]"])
|
||||
git(["config", "user.email", "41898282+github-actions[bot]@users.noreply.github.com"])
|
||||
git(["add", DOCS_PATH])
|
||||
git(["commit", "-m", `docs: sync with merged PRs (${date})`])
|
||||
|
||||
// The draft cap bounds the cumulative PR diff, not just this run's commit.
|
||||
const changedFiles = git(["diff", "--name-only", "origin/main...HEAD", "--", DOCS_PATH])
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
const draftReasons = []
|
||||
if (changedFiles.length > FILE_CAP) draftReasons.push(`diff exceeds ${FILE_CAP} files (${changedFiles.length})`)
|
||||
if (!verified) draftReasons.push("docs build/tests not passing")
|
||||
// Content gate: legitimate bot edits are docs pages and nav files. Anything
|
||||
// else in the docs package (build config, components, tests) executes
|
||||
// during the verify build, so force human review before merge.
|
||||
const nonContent = changedFiles.filter(
|
||||
(f) => !f.startsWith("packages/kilo-docs/pages/") && !f.startsWith("packages/kilo-docs/lib/nav/"),
|
||||
)
|
||||
if (nonContent.length > 0) {
|
||||
// File paths are agent-chosen; sanitize before they land in the PR body.
|
||||
const listed = nonContent
|
||||
.slice(0, 5)
|
||||
.map((f) => clean(f).replaceAll("|", "\\|"))
|
||||
.join(", ")
|
||||
draftReasons.push(`touches non-content files outside pages/ and lib/nav/: ${listed}`)
|
||||
}
|
||||
const draft = draftReasons.length > 0
|
||||
|
||||
git(mode === "update" ? ["push", "origin", `HEAD:${BRANCH}`] : ["push", "--force-with-lease", "origin", `HEAD:${BRANCH}`])
|
||||
|
||||
const changesNew = agentSummary.filter((e) => e.action !== "skipped").map(changeRow)
|
||||
const skippedNew = [
|
||||
...triage.filter((e) => e.docs_worthy === false),
|
||||
...agentSummary.filter((e) => e.action === "skipped"),
|
||||
].map(skippedRow)
|
||||
|
||||
let oldChanges = []
|
||||
let oldSkipped = []
|
||||
if (mode === "update" && existingPr) {
|
||||
const pr = await api(`/repos/${repo()}/pulls/${existingPr}`)
|
||||
oldChanges = extractSectionRows(pr.body, "changes")
|
||||
oldSkipped = extractSectionRows(pr.body, "skipped")
|
||||
}
|
||||
|
||||
const body = renderBody({
|
||||
date,
|
||||
since,
|
||||
through,
|
||||
changesRows: mergeRows(oldChanges, changesNew),
|
||||
skippedRows: mergeRows(oldSkipped, skippedNew),
|
||||
verified,
|
||||
draftReasons,
|
||||
note:
|
||||
mode === "conflict" && existingPr
|
||||
? `Continues from #${existingPr}, whose branch conflicted with \`main\` (its commits are preserved there).`
|
||||
: "",
|
||||
})
|
||||
|
||||
try {
|
||||
await api(`/repos/${repo()}/labels`, {
|
||||
method: "POST",
|
||||
body: { name: "auto-docs", color: "1d76db", description: "Automated docs-sync PRs" },
|
||||
})
|
||||
} catch (err) {
|
||||
if (err.status !== 422) throw err // 422 = label already exists
|
||||
}
|
||||
|
||||
let prNumber
|
||||
let prUrl
|
||||
if (mode === "update" && existingPr) {
|
||||
const pr = await api(`/repos/${repo()}/pulls/${existingPr}`, {
|
||||
method: "PATCH",
|
||||
body: { title: `docs: auto-sync with merged PRs (through ${date})`, body },
|
||||
})
|
||||
prNumber = pr.number
|
||||
prUrl = pr.html_url
|
||||
await api(`/repos/${repo()}/issues/${prNumber}/comments`, {
|
||||
method: "POST",
|
||||
body: {
|
||||
body: `(bot) Appended changes processed through \`${through}\`. Verification: **${verified ? "passing" : "failing"}**.${draft ? ` Draft because: ${draftReasons.join("; ")}.` : ""}`,
|
||||
},
|
||||
})
|
||||
} else {
|
||||
const pr = await api(`/repos/${repo()}/pulls`, {
|
||||
method: "POST",
|
||||
body: {
|
||||
title: `docs: auto-sync with merged PRs (through ${date})`,
|
||||
head: BRANCH,
|
||||
base: "main",
|
||||
body,
|
||||
draft,
|
||||
},
|
||||
})
|
||||
prNumber = pr.number
|
||||
prUrl = pr.html_url
|
||||
await api(`/repos/${repo()}/issues/${prNumber}/labels`, { method: "POST", body: { labels: ["auto-docs"] } })
|
||||
if (mode === "conflict" && existingPr) {
|
||||
await api(`/repos/${repo()}/issues/${existingPr}/comments`, {
|
||||
method: "POST",
|
||||
body: {
|
||||
body: `(bot) This branch conflicted with \`main\`, so the sync continues in ${prUrl}. Commits on this branch are preserved — please close this PR after the new one is reviewed.`,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
appendOutput("pr_url", prUrl)
|
||||
appendSummary(`### docs-sync PR\n\n- ${prUrl}\n- changed files: ${changedFiles.length}\n- draft: ${draft}\n`)
|
||||
console.log(`PR ${prNumber}: ${prUrl} (draft=${draft}, files=${changedFiles.length})`)
|
||||
}
|
||||
|
||||
const isMain = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href
|
||||
if (isMain) {
|
||||
main().catch((err) => {
|
||||
console.error(err)
|
||||
process.exit(1)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
// kilocode_change - new file
|
||||
|
||||
/**
|
||||
* Resolves the docs-sync watermark: the timestamp of the newest source PR the
|
||||
* bot has already processed. Derived from the bot's own PRs (marker in the PR
|
||||
* body), so there is no external state to keep consistent.
|
||||
*
|
||||
* Priority: workflow_dispatch input `since` > latest open bot PR marker >
|
||||
* last merged bot PR marker > 72h ago. Hard cap: never look back more than
|
||||
* 14 days.
|
||||
*/
|
||||
|
||||
import { appendOutput, appendSummary, repo, searchIssues } from "./lib.mjs"
|
||||
|
||||
const FALLBACK_HOURS = 72
|
||||
const CAP_DAYS = 14
|
||||
const MARKER = /<!--\s*docs-sync:\s*processed-through\s+(\S+?)\s*-->/
|
||||
|
||||
function extractMarker(body) {
|
||||
const m = (body ?? "").match(MARKER)
|
||||
if (!m) return null
|
||||
const d = new Date(m[1])
|
||||
return Number.isNaN(d.getTime()) ? null : d
|
||||
}
|
||||
|
||||
async function findWatermark() {
|
||||
const r = repo()
|
||||
for (const state of ["open", "merged"]) {
|
||||
const query = `repo:${r} is:pr label:auto-docs sort:created-desc ${state === "open" ? "is:open" : "is:merged"}`
|
||||
const prs = await searchIssues(query, { maxPages: 1 })
|
||||
for (const pr of prs) {
|
||||
// Only trust markers on PRs authored by the bot itself: bodies are
|
||||
// editable and the label can be applied by anyone with triage access.
|
||||
if (pr.user?.login !== "github-actions[bot]") continue
|
||||
const marker = extractMarker(pr.body)
|
||||
if (marker) {
|
||||
console.log(`watermark from ${state} PR #${pr.number}: ${marker.toISOString()}`)
|
||||
return marker
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
let since
|
||||
|
||||
const input = (process.env.INPUT_SINCE ?? "").trim()
|
||||
if (input) {
|
||||
since = new Date(input)
|
||||
if (Number.isNaN(since.getTime())) {
|
||||
throw new Error(`Invalid INPUT_SINCE: ${input}`)
|
||||
}
|
||||
console.log(`watermark from dispatch input: ${since.toISOString()}`)
|
||||
} else {
|
||||
since =
|
||||
(await findWatermark()) ?? new Date(now.getTime() - FALLBACK_HOURS * 3600 * 1000)
|
||||
}
|
||||
|
||||
// A forged, edited, or malformed marker in the future would silently match
|
||||
// nothing in the merged:>= search; clamp it loudly.
|
||||
if (since > now) {
|
||||
console.warn(`watermark ${since.toISOString()} is in the future, clamping to now`)
|
||||
since = now
|
||||
}
|
||||
|
||||
const cap = new Date(now.getTime() - CAP_DAYS * 24 * 3600 * 1000)
|
||||
if (since < cap) {
|
||||
console.log(`watermark ${since.toISOString()} older than ${CAP_DAYS}d cap, clamping`)
|
||||
since = cap
|
||||
}
|
||||
|
||||
appendOutput("since", since.toISOString())
|
||||
appendOutput("now", now.toISOString())
|
||||
appendSummary(`### docs-sync watermark\n\n- since: \`${since.toISOString()}\`\n- now: \`${now.toISOString()}\`\n`)
|
||||
@@ -0,0 +1,166 @@
|
||||
# kilocode_change - new file
|
||||
name: docs-sync
|
||||
|
||||
# Daily bot: collects PRs merged to Kilo-Org/cloud and Kilo-Org/kilocode,
|
||||
# triages them for docs relevance, runs Kilo CLI headless to update
|
||||
# packages/kilo-docs, and maintains one rolling PR for human review.
|
||||
#
|
||||
# Security posture: scheduled/manual only, checks out main, never executes
|
||||
# code from PR branches. State is derived from the bot's own PRs (watermark
|
||||
# marker in the PR body), so missed or failed runs self-heal on the next run.
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 7 * * *" # 07:00 UTC daily
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
since:
|
||||
description: "Override watermark (ISO date, e.g. 2026-07-20). Default: last processed-through marker, 72h fallback, 14d cap."
|
||||
required: false
|
||||
type: string
|
||||
dry_run:
|
||||
description: "Collect + triage only, no edits, no PR"
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
permissions:
|
||||
contents: write # push the rolling branch, create the auto-docs label
|
||||
pull-requests: write # create/update the rolling PR
|
||||
issues: write # comment on the rolling PR
|
||||
|
||||
concurrency:
|
||||
group: docs-sync
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
TRIAGE_MODEL: ${{ vars.DOCS_SYNC_TRIAGE_MODEL || 'kilo/moonshotai/kimi-k3' }}
|
||||
EDIT_MODEL: ${{ vars.DOCS_SYNC_EDIT_MODEL || 'kilo/moonshotai/kimi-k3' }}
|
||||
|
||||
jobs:
|
||||
sync:
|
||||
if: github.repository == 'Kilo-Org/kilocode'
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
timeout-minutes: 120
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0 # prepare-branch merges main into the rolling branch
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "24"
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Install Kilo CLI
|
||||
run: |
|
||||
npm install -g @kilocode/cli
|
||||
kilo --version
|
||||
|
||||
- name: Resolve watermark
|
||||
id: wm
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
INPUT_SINCE: ${{ inputs.since }}
|
||||
run: node .github/docs-sync/watermark.mjs
|
||||
|
||||
- name: Collect merged PRs
|
||||
id: collect
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: node .github/docs-sync/collect.mjs --since "${{ steps.wm.outputs.since }}"
|
||||
|
||||
- name: Triage merged PRs (LLM, chunked)
|
||||
id: triage
|
||||
if: steps.collect.outputs.count != '0'
|
||||
env:
|
||||
KILO_API_KEY: ${{ secrets.KILO_API_KEY }}
|
||||
run: node .github/docs-sync/triage.mjs
|
||||
|
||||
- name: Filter docs-worthy PRs
|
||||
id: worthy
|
||||
if: steps.collect.outputs.count != '0'
|
||||
run: |
|
||||
node .github/docs-sync/filter-worthy.mjs \
|
||||
docs-sync-out/digest-full.json docs-sync-out/triage.json docs-sync-out/worthy.json
|
||||
count=$(node -p "require('./docs-sync-out/worthy.json').length")
|
||||
echo "count=$count" >> "$GITHUB_OUTPUT"
|
||||
if [ "$count" = "0" ]; then
|
||||
echo "No docs-worthy PRs in this window; skipping edit/verify/PR."
|
||||
fi
|
||||
|
||||
- name: Setup Bun
|
||||
if: (steps.worthy.outputs.count || '0') != '0' && inputs.dry_run != true
|
||||
uses: ./.github/actions/setup-bun
|
||||
|
||||
- name: Prepare rolling branch
|
||||
id: prep
|
||||
if: (steps.worthy.outputs.count || '0') != '0' && inputs.dry_run != true
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: node .github/docs-sync/prepare-branch.mjs
|
||||
|
||||
- name: Update docs (Kilo CLI, batched)
|
||||
if: (steps.worthy.outputs.count || '0') != '0' && inputs.dry_run != true
|
||||
env:
|
||||
KILO_API_KEY: ${{ secrets.KILO_API_KEY }}
|
||||
run: node .github/docs-sync/edit.mjs
|
||||
|
||||
- name: Verify docs build and tests
|
||||
id: verify
|
||||
if: (steps.worthy.outputs.count || '0') != '0' && inputs.dry_run != true
|
||||
continue-on-error: true
|
||||
env:
|
||||
NEXT_PUBLIC_POSTHOG_KEY: ${{ secrets.POSTHOG_API_KEY }}
|
||||
run: |
|
||||
set -o pipefail
|
||||
{ bun run --filter @kilocode/kilo-docs build && bun run --filter @kilocode/kilo-docs test; } 2>&1 | tee docs-sync-out/verify.log
|
||||
|
||||
- name: Fix verify failures (one pass)
|
||||
id: fix
|
||||
if: steps.verify.outcome == 'failure'
|
||||
continue-on-error: true
|
||||
env:
|
||||
KILO_API_KEY: ${{ secrets.KILO_API_KEY }}
|
||||
NEXT_PUBLIC_POSTHOG_KEY: ${{ secrets.POSTHOG_API_KEY }}
|
||||
run: |
|
||||
set -o pipefail
|
||||
kilo run "The docs build or tests failed. Read the attached docs-sync-out/verify.log and fix the packages/kilo-docs changes so they pass. Do not revert doc edits; fix them. Do not modify anything outside packages/kilo-docs." \
|
||||
-m "$EDIT_MODEL" --dir "$GITHUB_WORKSPACE" -f docs-sync-out/verify.log \
|
||||
| tee -a docs-sync-out/edit-log.txt
|
||||
{ bun run --filter @kilocode/kilo-docs build && bun run --filter @kilocode/kilo-docs test; } 2>&1 | tee docs-sync-out/verify2.log
|
||||
|
||||
- name: Re-verify status
|
||||
id: verified
|
||||
if: (steps.worthy.outputs.count || '0') != '0' && inputs.dry_run != true
|
||||
env:
|
||||
VERIFY_OUTCOME: ${{ steps.verify.outcome }}
|
||||
FIX_OUTCOME: ${{ steps.fix.outcome }}
|
||||
run: |
|
||||
if [ "$VERIFY_OUTCOME" = "success" ] || [ "$FIX_OUTCOME" = "success" ]; then
|
||||
echo "ok=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "ok=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Upsert rolling PR
|
||||
if: (steps.worthy.outputs.count || '0') != '0' && inputs.dry_run != true
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
PROCESSED_THROUGH: ${{ steps.wm.outputs.now }}
|
||||
SINCE: ${{ steps.wm.outputs.since }}
|
||||
BRANCH: ${{ steps.prep.outputs.branch }}
|
||||
PREP_MODE: ${{ steps.prep.outputs.mode }}
|
||||
PR_NUMBER: ${{ steps.prep.outputs.pr_number }}
|
||||
VERIFIED: ${{ steps.verified.outputs.ok }}
|
||||
run: node .github/docs-sync/upsert-pr.mjs
|
||||
|
||||
- name: Upload run artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: docs-sync-out
|
||||
path: docs-sync-out/
|
||||
retention-days: 14
|
||||
if-no-files-found: ignore
|
||||
Reference in New Issue
Block a user