diff --git a/.github/docs-sync/edit.mjs b/.github/docs-sync/edit.mjs index 15b89734edc0..48a19a0c372c 100644 --- a/.github/docs-sync/edit.mjs +++ b/.github/docs-sync/edit.mjs @@ -10,7 +10,8 @@ * wall-clock budget is recorded as action "pending" so the watermark holds * back and the next run re-collects those PRs. * - * Env: EDIT_MODEL (provider/model), KILO_API_KEY + KILO_ORG_ID (set by workflow; read natively by the kilo provider). + * Env: EDIT_MODEL (provider/model), DOCS_SYNC_VARIANT (reasoning effort, default max), + * KILO_API_KEY + KILO_ORG_ID (set by workflow; read natively by the kilo provider). * Budgets: EDIT_BUDGET_MINUTES (default 50), EDIT_BATCH_TIMEOUT_MINUTES (default 15). * Test hook: DOCS_SYNC_BACKOFF_MS replaces every retry wait when set. */ @@ -18,7 +19,7 @@ import fs from "node:fs" import path from "node:path" import { fileURLToPath } from "node:url" -import { backoffMsForAttempt, deadline, remainingMs, runKilo, sleepSync } from "./lib.mjs" +import { backoffMsForAttempt, deadline, remainingMs, REASONING_VARIANT, runKilo, sleepSync } from "./lib.mjs" import { readLearningsBlock } from "./learn.mjs" const BATCH_SIZE = 5 @@ -89,7 +90,7 @@ Batch specifics for this run: the PRs to handle are in the attached ${batchFile} "-m", model, "--variant", - "high", + REASONING_VARIANT, "--dir", process.cwd(), "-f", diff --git a/.github/docs-sync/learn.mjs b/.github/docs-sync/learn.mjs index 6baa59124e23..2de14b2fa892 100644 --- a/.github/docs-sync/learn.mjs +++ b/.github/docs-sync/learn.mjs @@ -9,7 +9,8 @@ * node learn.mjs — extraction: fetch corrections, call the model, validate * node learn.mjs --apply — apply: write learnings.json into LEARNINGS.md * - * Env: TRIAGE_MODEL (provider/model, reused), GH_TOKEN (or GITHUB_TOKEN). + * Env: TRIAGE_MODEL (provider/model, reused), DOCS_SYNC_VARIANT (reasoning effort, default max), + * GH_TOKEN (or GITHUB_TOKEN). * Budget: LEARNINGS_BUDGET_MINUTES (default 10). * Test hook: DOCS_SYNC_FIXTURE. When set to a fixture JSON path, skips every * GitHub API call and writes any marker PATCH to .patched instead of @@ -400,8 +401,17 @@ async function extract() { patchFile = fixturePath + ".patched" } - const { api, repo, searchIssues, appendOutput, appendSummary, backoffMsForAttempt, runKilo, sleepSync } = - await import("./lib.mjs") + const { + api, + repo, + searchIssues, + appendOutput, + appendSummary, + backoffMsForAttempt, + REASONING_VARIANT, + runKilo, + sleepSync, + } = await import("./lib.mjs") let prData let prBody = "" @@ -712,7 +722,7 @@ async function extract() { } const result = runKilo({ - args: ["run", prompt, "-m", model, "--dir", process.cwd(), "-f", inputFile], + args: ["run", prompt, "-m", model, "--variant", REASONING_VARIANT, "--dir", process.cwd(), "-f", inputFile], timeoutMs: Math.min(EXTRACTION_TIMEOUT_MS, left), streamStdout: false, label: "learnings extraction", diff --git a/.github/docs-sync/lib.mjs b/.github/docs-sync/lib.mjs index f63155adf668..047e2e52eb57 100644 --- a/.github/docs-sync/lib.mjs +++ b/.github/docs-sync/lib.mjs @@ -14,6 +14,11 @@ import fs from "node:fs" const API = process.env.DOCS_SYNC_API_BASE || "https://api.github.com" const MAX_RETRIES = 3 +// Reasoning effort passed to every `kilo run` as `--variant`. The workflow sets +// DOCS_SYNC_VARIANT (default "max"); scripts fall back to max so a local run or a +// caller that forgets the env still gets the intended effort. +export const REASONING_VARIANT = process.env.DOCS_SYNC_VARIANT || "max" + 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") diff --git a/.github/docs-sync/model-config.test.mjs b/.github/docs-sync/model-config.test.mjs new file mode 100644 index 000000000000..a4543c4caa0c --- /dev/null +++ b/.github/docs-sync/model-config.test.mjs @@ -0,0 +1,166 @@ +// kilocode_change - new file + +/** + * Guards the docs-sync model + reasoning-effort wiring. + * + * The bot runs DeepSeek V4.1 Flash at maximum reasoning effort on every LLM + * call. The id and effort are easy to drift (a hand-edited workflow env, a new + * `kilo run` call site that forgets `--variant`), so this ordinary unit test + * asserts the wiring from the source of truth: the workflow env defaults, the + * shared `REASONING_VARIANT` constant, and every `args` array passed to + * `runKilo` in triage.mjs, edit.mjs, learn.mjs, plus the workflow's fix step. + * + * Run: node .github/docs-sync/model-config.test.mjs + */ + +import assert from "node:assert/strict" +import fs from "node:fs" +import path from "node:path" +import { fileURLToPath } from "node:url" + +const HERE = path.dirname(fileURLToPath(import.meta.url)) +const ROOT = path.resolve(HERE, "..", "..") +const read = (p) => fs.readFileSync(path.join(ROOT, p), "utf8") + +const MODEL = "kilo/deepseek/deepseek-v4.1-flash" +const MAX = "max" + +const workflow = read(".github/workflows/docs-sync.yml") +const lib = read(".github/docs-sync/lib.mjs") + +const failures = [] +function check(label, fn) { + try { + fn() + console.log(`ok - ${label}`) + } catch (err) { + failures.push(`${label}: ${err.message}`) + console.error(`not ok - ${label}: ${err.message}`) + } +} + +/** Every `args: [ ... ]` array literal passed to a `runKilo(` call, as source text. */ +function runKiloArgArrays(src) { + const arrays = [] + const marker = "runKilo(" + let i = src.indexOf(marker) + while (i !== -1) { + const argsIdx = src.indexOf("args:", i) + const start = src.indexOf("[", argsIdx) + assert.ok(argsIdx !== -1 && start !== -1, "runKilo call without an args array") + let depth = 0 + let j = start + for (; j < src.length; j++) { + if (src[j] === "[") depth++ + else if (src[j] === "]") { + depth-- + if (depth === 0) break + } + } + arrays.push(src.slice(start, j + 1)) + i = src.indexOf(marker, j) + } + return arrays +} + +/** The workflow step block whose `- name:` line starts with `prefix`. */ +function stepBlock(prefix) { + const blocks = workflow.split("\n - name: ") + return blocks.find((b) => b.startsWith(prefix)) ?? null +} + +// 1. Model defaults: DeepSeek 4.1 Flash via the kilo provider, vars. override kept. +for (const [key, varName] of [ + ["TRIAGE_MODEL", "DOCS_SYNC_TRIAGE_MODEL"], + ["EDIT_MODEL", "DOCS_SYNC_EDIT_MODEL"], +]) { + check(`${key} defaults to ${MODEL} with a vars.${varName} override`, () => { + const re = new RegExp(`${key}:\\s*\\$\\{\\{\\s*vars\\.${varName}\\s*\\|\\|\\s*'([^']+)'\\s*\\}\\}`) + const m = workflow.match(re) + assert.ok(m, `${key} env line with a vars.${varName} override was not found`) + assert.equal(m[1], MODEL, `${key} default is ${m[1]}, expected ${MODEL}`) + }) +} + +// 2. The effort is overridable but defaults to max. +check(`DOCS_SYNC_VARIANT defaults to "${MAX}"`, () => { + assert.match(workflow, /^\s*DOCS_SYNC_VARIANT:\s*"max"\s*$/m, 'top-level env DOCS_SYNC_VARIANT is not "max"') +}) + +// 2b. The comment above it cites where the id and variants come from, so a +// future id/effort change can be re-verified against the live list. +check("workflow comment cites the Kilo gateway model list", () => { + assert.match(workflow, /api\.kilo\.ai\/api\/openrouter\/models/, "the model source URL is not cited in the workflow") +}) + +// 3. One shared constant so all call sites agree. +check("lib.mjs exports REASONING_VARIANT defaulting to max", () => { + assert.match( + lib, + /export const REASONING_VARIANT = process\.env\.DOCS_SYNC_VARIANT \|\| "max"/, + "REASONING_VARIANT constant not found or not defaulting to max", + ) +}) + +// 3b. The constant actually resolves to max when the workflow env is absent. +delete process.env.DOCS_SYNC_VARIANT +const { REASONING_VARIANT } = await import("./lib.mjs") +check(`REASONING_VARIANT resolves to "${MAX}" by default`, () => { + assert.equal(REASONING_VARIANT, MAX) +}) + +// 4. Every script's `kilo run` argv carries -m and --variant REASONING_VARIANT. +for (const file of ["triage.mjs", "edit.mjs", "learn.mjs"]) { + const src = read(`.github/docs-sync/${file}`) + const arrays = runKiloArgArrays(src) + + check(`${file} has at least one runKilo call`, () => { + assert.ok(arrays.length > 0, "no runKilo call found") + }) + + arrays.forEach((args, index) => { + check(`${file} runKilo args #${index + 1} pass -m and --variant REASONING_VARIANT`, () => { + assert.ok(args.includes('"run"'), `argv does not start the run command: ${args}`) + assert.ok(args.includes('"-m"'), `argv is missing -m: ${args}`) + assert.ok(args.includes('"--variant"'), `argv is missing --variant: ${args}`) + assert.ok(args.includes("REASONING_VARIANT"), `argv must pass --variant REASONING_VARIANT: ${args}`) + }) + }) +} + +// 5. The deliberate --auto difference is preserved: triage/edit grant bash to the +// agent; the learnings extraction stays tool-free (learn.mjs:694-697). +for (const file of ["triage.mjs", "edit.mjs"]) { + check(`${file} keeps --auto`, () => { + const arrays = runKiloArgArrays(read(`.github/docs-sync/${file}`)) + for (const args of arrays) assert.ok(args.includes('"--auto"'), `argv is missing --auto: ${args}`) + }) +} +check("learn.mjs stays without --auto", () => { + const arrays = runKiloArgArrays(read(".github/docs-sync/learn.mjs")) + for (const args of arrays) assert.ok(!args.includes('"--auto"'), `learn.mjs must not pass --auto: ${args}`) +}) + +// 6. The workflow's fix step passes the same effort as the scripts. +check('workflow fix step passes --variant "$DOCS_SYNC_VARIANT"', () => { + const block = stepBlock("Fix verify failures") + assert.ok(block, "Fix verify failures step not found") + assert.ok(block.includes("kilo run"), "fix step no longer runs `kilo run`") + assert.ok(block.includes('--variant "$DOCS_SYNC_VARIANT"'), 'fix step is missing --variant "$DOCS_SYNC_VARIANT"') +}) + +// 7. Every `kilo run` in the workflow lives in a step that also passes --variant. +check("every workflow kilo run passes --variant", () => { + const runs = workflow.split("\n - name: ").filter((b) => /(^|\s)kilo run(\s|$)/m.test(b)) + assert.ok(runs.length > 0, "no `kilo run` found in the workflow") + for (const block of runs) { + const name = block.split("\n")[0] + assert.ok(block.includes("--variant"), `step "${name}" runs kilo without --variant`) + } +}) + +if (failures.length > 0) { + console.error(`\n${failures.length} check(s) failed`) + process.exit(1) +} +console.log("\nmodel-config: all checks passed") diff --git a/.github/docs-sync/prepare-branch.mjs b/.github/docs-sync/prepare-branch.mjs index 2710832ccf31..ff5b715e916d 100644 --- a/.github/docs-sync/prepare-branch.mjs +++ b/.github/docs-sync/prepare-branch.mjs @@ -1,19 +1,30 @@ // 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) + * Prepares the rolling docs-sync integration branch before the edit pass. * - * Outputs: branch, mode (update|fresh|conflict), pr_number (empty when fresh). + * The integration branch is always `DEFAULT_BRANCH` (`docs/auto-sync`) and + * carries the accumulating tree; it has no PR of its own. This step: + * - checks it out (fetching it when it exists on origin, else creating it + * from origin/main) and merges origin/main via mergeOrFallback + * - merges each open per-surface `docs/auto-sync/` PR branch + * (best effort; a per-branch conflict is aborted and skipped) + * - comments on and closes the legacy single rolling PR (head exactly + * `DEFAULT_BRANCH`), which the per-surface PRs supersede + * + * Outputs: branch, mode (update|fresh|conflict), pr_number (the legacy PR + * number when one was closed, else empty). */ import { execFileSync } from "node:child_process" import { pathToFileURL } from "node:url" import { api, appendOutput, repo, searchIssues } from "./lib.mjs" +import { surfaceBranchPrefix } from "./surfaces.mjs" export const DEFAULT_BRANCH = "docs/auto-sync" +// Git refs cannot hold both `docs/auto-sync` and `docs/auto-sync/`, so +// the integration tree is pushed to this sibling durability ref instead. +export const INTEGRATION_BRANCH = "docs/auto-sync-integration" const defaultGit = (args) => execFileSync("git", args, { stdio: ["ignore", "pipe", "inherit"] }).toString().trim() @@ -64,36 +75,151 @@ export function mergeOrFallback({ branch, git = defaultGit }) { } } +/** + * The legacy single rolling PR has head exactly `defaultBranch`. A per-surface + * branch (head under `surfacePrefix`) is never the legacy PR, so the two can + * never be confused even if `defaultBranch` is a prefix of the branch name. + */ +export function isLegacyRollingPr(pr, defaultBranch = DEFAULT_BRANCH, surfacePrefix = surfaceBranchPrefix()) { + const head = String(pr?.head?.ref ?? "") + if (!head) return false + if (surfacePrefix && head.startsWith(surfacePrefix)) return false + return head === defaultBranch +} + +/** + * Best-effort merge of one surface branch into the integration branch. The + * fetched ref lands under `refs/docs-sync/surfaces/` so it cannot collide with + * the integration branch's own remote-tracking ref (`docs/auto-sync` may not be + * a path prefix of `docs/auto-sync/`). + */ +function mergeSurface(git, ref, name) { + const local = `refs/docs-sync/surfaces/${name}` + try { + git(["fetch", "origin", `+refs/heads/${ref}:${local}`]) + git(["merge", local, "--no-edit"]) + return true + } catch (err) { + let unmerged = "" + try { + unmerged = git(["ls-files", "--unmerged"]) + } catch { + // ls-files failing is not a conflict signal + } + let inProgress = false + try { + git(["rev-parse", "-q", "--verify", "MERGE_HEAD"]) + inProgress = true + } catch { + inProgress = false + } + if (unmerged.length > 0 || inProgress) { + try { + git(["merge", "--abort"]) + } catch (abortErr) { + console.warn(`::warning::docs-sync: could not abort merge of ${ref}: ${abortErr.message}`) + } + } + console.warn(`::warning::docs-sync: could not merge ${ref} into the integration branch: ${err.message}`) + return false + } +} + async function main() { const git = defaultGit - const prs = await searchIssues(`repo:${repo()} is:pr is:open label:auto-docs sort:created-desc`, { maxPages: 1 }) + // Page or two covers one PR per surface plus the legacy rolling PR. + const prs = await searchIssues(`repo:${repo()} is:pr is:open label:auto-docs sort:created-desc`, { maxPages: 2 }) + + const details = [] + for (const pr of prs) { + try { + details.push(await api(`/repos/${repo()}/pulls/${pr.number}`)) + } catch (err) { + console.warn(`::warning::docs-sync: could not read auto-docs PR #${pr.number}: ${err.message}`) + } + } + + const legacy = details.find((pr) => isLegacyRollingPr(pr, DEFAULT_BRANCH, surfaceBranchPrefix())) ?? null + const surfacePrs = details.filter((pr) => String(pr?.head?.ref ?? "").startsWith(surfaceBranchPrefix())) - let mode = "fresh" - let prNumber = "" let branch = DEFAULT_BRANCH + let mode = "fresh" - 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]) - ;({ branch, mode } = mergeOrFallback({ branch, git })) - } 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. + // Integration base order: the durability ref, then the legacy rolling branch, + // then a fresh checkout of origin/main. + let base = null + const bases = [ + { remote: INTEGRATION_BRANCH, local: INTEGRATION_BRANCH }, + { remote: DEFAULT_BRANCH, local: `${DEFAULT_BRANCH}-legacy` }, + ] + for (const candidate of bases) { + if (base) break try { - git(["fetch", "origin", `+refs/heads/${branch}:refs/remotes/origin/${branch}`]) + git(["fetch", "origin", `+refs/heads/${candidate.remote}:refs/remotes/origin/${candidate.local}`]) + base = `origin/${candidate.local}` } catch { - console.log(`branch ${branch} does not exist on origin yet; will create it on push`) + console.log(`branch ${candidate.remote} does not exist on origin yet`) + } + } + + if (base) { + git(["checkout", "-B", DEFAULT_BRANCH, base]) + ;({ branch, mode } = mergeOrFallback({ branch: DEFAULT_BRANCH, git })) + } else { + git(["checkout", "-B", DEFAULT_BRANCH, "origin/main"]) + } + + // Human commits on a surface PR must stay in the integration tree. A single + // per-branch conflict warns and continues — it must never fail the run. + for (const pr of surfacePrs) { + const ref = pr.head.ref + const name = ref.slice(surfaceBranchPrefix().length) || "unknown" + if (mergeSurface(git, ref, name)) console.log(`merged ${ref} into ${branch}`) + } + + // The legacy rolling PR is superseded: its branch content already went into + // the integration base above. Comment and close it (best effort) so exactly + // one PR per surface plus `other` remain. + if (legacy) { + try { + await api(`/repos/${repo()}/issues/${legacy.number}/comments`, { + method: "POST", + body: { + body: `(bot) This rolling PR is superseded by the per-surface docs-sync PRs (one per product surface plus \`other\`). Its branch \`${DEFAULT_BRANCH}\` is now only the integration base, so please review the per-surface PRs instead.`, + }, + }) + } catch (err) { + console.warn(`::warning::docs-sync: could not comment on the legacy rolling PR #${legacy.number}: ${err.message}`) + } + try { + await api(`/repos/${repo()}/pulls/${legacy.number}`, { method: "PATCH", body: { state: "closed" } }) + } catch (err) { + console.warn(`::warning::docs-sync: could not close the legacy rolling PR #${legacy.number}: ${err.message}`) + } + // A ref may not be a path prefix of another ref, so `docs/auto-sync` must be + // gone for `docs/auto-sync/` to exist. Its content already lives + // in the integration base and the per-surface branches. + try { + git(["push", "origin", "--delete", DEFAULT_BRANCH]) + } catch (err) { + console.warn(`::warning::docs-sync: could not delete the legacy branch ${DEFAULT_BRANCH}: ${err.message}`) + } + // Drop the stale remote-tracking ref too, otherwise fetching a + // `docs/auto-sync/` branch later in the run fails on the + // directory/file ref conflict. + try { + git(["update-ref", "-d", `refs/remotes/origin/${DEFAULT_BRANCH}`]) + } catch (err) { + console.warn(`::warning::docs-sync: could not remove the stale ref for ${DEFAULT_BRANCH}: ${err.message}`) } - 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"})`) + appendOutput("pr_number", legacy ? String(legacy.number) : "") + console.log( + `branch ${branch} ready (mode=${mode}, legacyPr=${legacy ? legacy.number : "none"}, surfacePrs=${surfacePrs.length})`, + ) } const isMain = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href diff --git a/.github/docs-sync/reviewers.mjs b/.github/docs-sync/reviewers.mjs new file mode 100644 index 000000000000..99c819d1902e --- /dev/null +++ b/.github/docs-sync/reviewers.mjs @@ -0,0 +1,141 @@ +// kilocode_change - new file + +/** + * Reviewer ranking for the docs-sync surface PRs. + * + * `rankContributors` is pure: it turns commit metadata into a recency-weighted + * leaderboard. `computeSurfaceReviewers` is the side-effecting wrapper that + * reads the committed surface map, asks the GitHub API for commits touching a + * surface's source prefixes, then asks for the top candidates' permission until + * two people with write access are found. Both are dependency-free so the + * workflow needs no extra package. + */ + +import { SURFACE_MAP_PATH, OTHER, loadSurfaceMap, surfaceReviewers, surfaceSourcePrefixes } from "./surfaces.mjs" + +const BOT_LOGIN = /\[bot\]$/i +const DAY_MS = 86_400_000 +const DEFAULT_HALF_LIFE_DAYS = 180 +// Any of GitHub's write-capable repository permission levels. +const WRITE_PERMISSIONS = ["admin", "write", "maintain"] + +function paths(prefixes) { + return prefixes.map((p) => `\`${p}\``).join(", ") +} + +/** + * Recency-weighted contributor leaderboard. + * + * `commits` is `[{ login, type, date }]`. Each commit contributes + * `0.5 ** (ageDays / halfLifeDays)` to its author's score, so a commit one + * half-life old counts half as much as one today. Bots (author `type === "Bot"` + * or a login ending in `[bot]`) are skipped. Sorted by score desc, then login. + */ +export function rankContributors(commits, now, { halfLifeDays = DEFAULT_HALF_LIFE_DAYS } = {}) { + const nowMs = now instanceof Date ? now.getTime() : typeof now === "number" ? now : Date.parse(now) + const half = halfLifeDays > 0 ? halfLifeDays : DEFAULT_HALF_LIFE_DAYS + const totals = new Map() + + for (const commit of Array.isArray(commits) ? commits : []) { + const login = commit?.login + if (!login) continue + if (commit?.type === "Bot") continue + if (BOT_LOGIN.test(login)) continue + const at = Date.parse(commit?.date) + if (!Number.isFinite(at) || !Number.isFinite(nowMs)) continue + const ageDays = Math.max(0, (nowMs - at) / DAY_MS) + const entry = totals.get(login) ?? { login, score: 0, count: 0 } + entry.score += 0.5 ** (ageDays / half) + entry.count += 1 + totals.set(login, entry) + } + + return [...totals.values()].sort((a, b) => b.score - a.score || a.login.localeCompare(b.login)) +} + +/** + * The two reviewers for a surface. + * + * `other` has no source paths, so it returns the fixed pair configured in the + * map without touching the API. Every other surface is ranked from the git + * history of its source prefixes; candidates are walked in rank order and the + * first two with admin/write/maintain permission win. On a missing token, a + * missing repo, or any API failure it returns no reviewers and a `note` naming + * the reason so the caller can print it in the PR body instead of guessing. + */ +export async function computeSurfaceReviewers(surface, { api, repo, now, map } = {}) { + const m = map ?? loadSurfaceMap() + const otherName = m?.other?.name ?? OTHER + + if (surface === otherName || surface === OTHER) { + const reviewers = surfaceReviewers(otherName, m) + return { + reviewers: [...reviewers], + note: `\`${otherName}\` has no source paths to rank, so it keeps the fixed reviewers ${reviewers + .map((r) => `@${r}`) + .join(" and ")} from surfaces.json.`, + sourcePrefixes: [], + } + } + + const prefixes = surfaceSourcePrefixes(surface, m) + if (prefixes.length === 0) { + return { + reviewers: [], + note: `no source prefixes are configured for surface \`${surface}\` in ${SURFACE_MAP_PATH}.`, + sourcePrefixes: [], + } + } + if (!repo) { + return { + reviewers: [], + note: `GITHUB_REPOSITORY is missing, so reviewers for \`${surface}\` could not be ranked (paths: ${paths(prefixes)}).`, + sourcePrefixes: prefixes, + } + } + if (typeof api !== "function") { + return { + reviewers: [], + note: `no GitHub API client was supplied, so reviewers for \`${surface}\` could not be ranked (paths: ${paths(prefixes)}).`, + sourcePrefixes: prefixes, + } + } + + try { + const commits = [] + for (const prefix of prefixes) { + const batch = await api(`/repos/${repo}/commits?path=${encodeURIComponent(prefix)}&per_page=100`) + for (const commit of Array.isArray(batch) ? batch : []) { + commits.push({ login: commit?.author?.login, type: commit?.author?.type, date: commit?.commit?.author?.date }) + } + } + + const ranked = rankContributors(commits, now ?? Date.now()) + const reviewers = [] + for (const candidate of ranked) { + if (reviewers.length >= 2) break + let level + try { + const perm = await api(`/repos/${repo}/collaborators/${candidate.login}/permission`) + level = perm?.permission ?? perm?.role_name + } catch (err) { + // 404 = not a collaborator; skip and try the next candidate. + if (err?.status === 404) continue + throw err + } + if (WRITE_PERMISSIONS.includes(level)) reviewers.push(candidate.login) + } + + return { + reviewers, + note: `Reviewers for \`${surface}\` are ranked from git history over ${paths(prefixes)} (a commit ${DEFAULT_HALF_LIFE_DAYS} days old counts half as much, half-life ${DEFAULT_HALF_LIFE_DAYS} days). Bots (author type "Bot" or a login matching /\\[bot\\]$/i) and people without admin, write, or maintain permission are excluded.`, + sourcePrefixes: prefixes, + } + } catch (err) { + return { + reviewers: [], + note: `could not rank reviewers for \`${surface}\` over ${paths(prefixes)}: ${err?.message ?? err}`, + sourcePrefixes: prefixes, + } + } +} diff --git a/.github/docs-sync/scope.test.mjs b/.github/docs-sync/scope.test.mjs new file mode 100644 index 000000000000..a38c97dffdd9 --- /dev/null +++ b/.github/docs-sync/scope.test.mjs @@ -0,0 +1,50 @@ +// kilocode_change - new file + +/** + * Scope guard for the docs-sync change. + * + * The `bun install` run behind this change pruned + * `patches/ghostty-web@0.3.0.patch` and let bun rewrite the lockfile's + * `trustedDependencies` and `patchedDependencies` lists in its own order. Both + * are the package manager's output and are left exactly as `bun install` wrote + * them — restoring either by hand would only be pruned and re-sorted again. + * + * This guard records why the patch is genuinely orphaned, and fails if the + * docs-sync surface files go missing. + * + * Run: node .github/docs-sync/scope.test.mjs + */ + +import assert from "node:assert/strict" +import fs from "node:fs" +import path from "node:path" +import test from "node:test" +import { fileURLToPath } from "node:url" + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..") +const read = (file) => fs.readFileSync(path.join(ROOT, file), "utf8") + +test("the ghostty-web patch is orphaned, so bun install prunes it", () => { + const root = JSON.parse(read("package.json")) + const patched = Object.keys(root.patchedDependencies ?? {}) + assert.ok( + !patched.some((key) => key.startsWith("ghostty-web")), + `nothing patches ghostty-web, so patches/ghostty-web@0.3.0.patch is orphaned and bun install prunes it; patchedDependencies: ${patched.join(", ")}`, + ) + // The only consumer is on 0.4.0, so nothing looks for the 0.3.0 patch. + const consumer = JSON.parse(read("packages/kilo-console/package.json")) + const version = consumer.dependencies?.["ghostty-web"] ?? consumer.devDependencies?.["ghostty-web"] ?? "" + assert.match(version, /^0\.4\./, "the only ghostty-web consumer must be on 0.4.x") +}) + +test("the docs-sync surface files are still present", () => { + for (const file of ["surfaces.json", "surfaces.mjs", "reviewers.mjs", "prepare-branch.mjs", "upsert-pr.mjs"]) { + assert.ok(fs.existsSync(path.join(ROOT, ".github/docs-sync", file)), `.github/docs-sync/${file} must exist`) + } + const map = JSON.parse(read(".github/docs-sync/surfaces.json")) + const names = (map.surfaces ?? []).map((s) => s.name) + for (const name of ["cli", "vscode", "jetbrains", "gateway", "web"]) { + assert.ok(names.includes(name), `surface map must still enumerate ${name}`) + } + assert.equal(map.other?.name, "other") +}) diff --git a/.github/docs-sync/selftest.mjs b/.github/docs-sync/selftest.mjs index 8341b0ef9adc..0887c48a4958 100644 --- a/.github/docs-sync/selftest.mjs +++ b/.github/docs-sync/selftest.mjs @@ -14,7 +14,7 @@ import path from "node:path" import { fileURLToPath } from "node:url" import { sleepSync } from "./lib.mjs" -import { mergeOrFallback, DEFAULT_BRANCH } from "./prepare-branch.mjs" +import { mergeOrFallback, DEFAULT_BRANCH, isLegacyRollingPr } from "./prepare-branch.mjs" import { applyCap } from "./watermark.mjs" import { computeUncovered, @@ -27,6 +27,7 @@ import { resolveLearnedThrough, renderBody, extractSectionRows, + surfaceBranch, } from "./upsert-pr.mjs" import { revertTitleKind, @@ -52,6 +53,8 @@ const EDIT_SCRIPT = path.join(HERE, "edit.mjs") const TRIAGE_SCRIPT = path.join(HERE, "triage.mjs") const COLLECT_SCRIPT = path.join(HERE, "collect.mjs") const LEARN_SCRIPT = path.join(HERE, "learn.mjs") +const UPSERT_SCRIPT = path.join(HERE, "upsert-pr.mjs") +const PREP_SCRIPT = path.join(HERE, "prepare-branch.mjs") const temps = [] @@ -2956,19 +2959,19 @@ Just prose, not a rule line. ) assert.ok(upsertSrc.includes("prBody"), "resolveLearnedThrough must receive prBody") - // (b) prBody is at function scope (let prBody before the if block) + // (b) prBody is function-scoped and read before the body is rendered const prBodyIdx = upsertSrc.indexOf('let prBody = ""') assert.ok(prBodyIdx >= 0, 'prBody must be declared at function scope with let prBody = ""') - const ifIdx = upsertSrc.indexOf('if (mode === "update"') - assert.ok(prBodyIdx < ifIdx, 'let prBody must appear before if (mode === "update"...)') - // (c) renderBody({ argument object contains learnedThrough + // (c) the renderBody({ argument object contains learnedThrough + surfaceBlock const renderBodyIdx = upsertSrc.indexOf("const body = renderBody({") assert.ok(renderBodyIdx >= 0, "renderBody call must exist") + assert.ok(prBodyIdx < renderBodyIdx, "let prBody must appear before the renderBody call") const afterRenderBody = upsertSrc.slice(renderBodyIdx) const renderBodyArgsEnd = afterRenderBody.indexOf("})") const renderBodyArgs = afterRenderBody.slice(0, renderBodyArgsEnd) - assert.ok(renderBodyArgs.includes("learnedThrough"), "renderBody call in main() must pass learnedThrough") + assert.ok(renderBodyArgs.includes("learnedThrough"), "renderBody call must pass learnedThrough") + assert.ok(renderBodyArgs.includes("surfaceBlock"), "renderBody call must pass the surface block") } // 10q — dry run makes no live write @@ -3429,26 +3432,491 @@ server.listen(0, "127.0.0.1", () => fs.writeFileSync(process.env.PORT_FILE, Stri } } -// Case 11 — the created rolling PR gets an assignee and a review request +// Case 11 — created per-surface PRs get an assignee and a review request function case11_prOwner() { - console.log("case 11 — created PR assignee and reviewer") - const src = fs.readFileSync(path.join(HERE, "upsert-pr.mjs"), "utf8") + console.log("case 11 — per-surface assignee and reviewer calls") + const src = fs.readFileSync(UPSERT_SCRIPT, "utf8") - assert.ok(/const DOCS_OWNER = "\S+"/.test(src), "DOCS_OWNER must be a module constant") + // The single rolling owner is gone; reviewers come from the surface map. + assert.ok(!src.includes("DOCS_OWNER"), "the single DOCS_OWNER constant must be removed") + assert.ok(src.includes("computeSurfaceReviewers"), "upsert must compute per-surface reviewers") + assert.ok(src.includes("groupBySurface"), "upsert must group docs changes by surface") + assert.equal(surfaceBranch("cli"), "docs/auto-sync/cli") + assert.equal(surfaceBranch("other"), "docs/auto-sync/other") + + // Created PRs still POST assignees and requested_reviewers, best-effort. assert.ok(src.includes("/assignees`, {"), "created PR must POST assignees") assert.ok(src.includes("/requested_reviewers`, {"), "created PR must POST requested_reviewers") + const assignIdx = src.indexOf("/assignees`, {") + const reviewIdx = src.indexOf("/requested_reviewers`, {") + const tryIdx = src.lastIndexOf("try {", assignIdx) + const catchIdx = src.indexOf("} catch", assignIdx) + assert.ok(tryIdx >= 0 && catchIdx > reviewIdx, "both POSTs must sit in one try/catch") + + // Legacy rolling PR helper: only the bare integration branch, never a + // per-surface branch. + assert.equal(isLegacyRollingPr({ head: { ref: DEFAULT_BRANCH } }), true) + assert.equal(isLegacyRollingPr({ head: { ref: surfaceBranch("cli") } }), false) + assert.equal(isLegacyRollingPr({ head: { ref: `${DEFAULT_BRANCH}-2026-09-01` } }), false) + assert.equal(isLegacyRollingPr({ head: { ref: "" } }), false) + assert.equal(isLegacyRollingPr({}), false) + + // prepare-branch no longer repurposes the legacy PR: it comments and closes. + const prepSrc = fs.readFileSync(path.join(HERE, "prepare-branch.mjs"), "utf8") + assert.ok(prepSrc.includes("isLegacyRollingPr"), "prepare-branch must detect the legacy PR") + assert.ok(prepSrc.includes('state: "closed"'), "prepare-branch must close the legacy PR") + assert.ok(prepSrc.includes("surfaceBranchPrefix"), "prepare-branch must match per-surface branches") +} + +// --------------------------------------------------------------------------- +// Case 12/13 — per-surface segmentation against a stub API +// --------------------------------------------------------------------------- +const SURFACE_NOW = "2026-09-01T00:00:00.000Z" +const SURFACE_PREFIX = "docs/auto-sync/" +const SURFACE_PAGE_FILES = { + cli: "packages/kilo-docs/pages/getting-started/new.md", + vscode: "packages/kilo-docs/pages/code-with-ai/platforms/vscode/new.md", + gateway: "packages/kilo-docs/pages/gateway/new.md", + other: "packages/kilo-docs/pages/community/new.md", +} + +// Stub GitHub API. Routes are narrow: search, one pull read, commits by path, +// collaborator permission, and the write endpoints we capture. No network. +const SURFACE_STUB_SERVER = ` +const fs = require("node:fs") +const http = require("node:http") +const config = JSON.parse(fs.readFileSync(process.env.STUB_CONFIG_FILE, "utf8")) +let nextPr = 100 +function log(entry) { fs.appendFileSync(process.env.STUB_LOG_FILE, JSON.stringify(entry) + "\\n") } +function send(res, status, data) { + res.writeHead(status, { "content-type": "application/json" }) + res.end(JSON.stringify(data)) +} +const server = http.createServer((req, res) => { + let raw = "" + req.on("data", (c) => (raw += c)) + req.on("end", () => { + let body = null + if (raw) { try { body = JSON.parse(raw) } catch (e) { body = raw } } + const path = String(req.url).split("?")[0] + log({ method: req.method, url: req.url, body }) + if (req.method === "GET" && path === "/search/issues") return send(res, 200, { items: config.openPrs || [] }) + const pullMatch = path.match(/^\\/repos\\/[^/]+\\/[^/]+\\/pulls\\/(\\d+)$/) + if (req.method === "GET" && pullMatch) { + const n = Number(pullMatch[1]) + const pr = (config.openPrs || []).find((p) => p.number === n) || {} + return send(res, 200, { number: n, head: { ref: pr.head || "" }, body: pr.body || "", html_url: "https://example.test/pull/" + n }) + } + if (req.method === "GET" && path.endsWith("/commits")) { + const query = String(req.url).split("?")[1] || "" + const m = query.match(/path=([^&]*)/) + const prefix = m ? decodeURIComponent(m[1]) : "" + return send(res, 200, (config.commits || {})[prefix] || []) + } + if (req.method === "GET" && path.indexOf("/collaborators/") >= 0 && path.endsWith("/permission")) { + const login = decodeURIComponent(path.split("/collaborators/")[1].replace("/permission", "")) + return send(res, 200, { permission: (config.permissions || {})[login] || "write" }) + } + if (req.method === "POST" && /\\/pulls$/.test(path)) { + const head = body && body.head + if (config.failHead && head === config.failHead) return send(res, 400, { message: "stub rejected " + head }) + const number = nextPr++ + const url = "https://example.test/pull/" + number + log({ kind: "create", head: head, number: number, url: url, body: body }) + return send(res, 201, { number: number, html_url: url, head: { ref: head } }) + } + if (req.method === "PATCH" && pullMatch) return send(res, 200, { number: Number(pullMatch[1]), html_url: "https://example.test/pull/" + pullMatch[1] }) + if (req.method === "POST" && path.endsWith("/labels")) return send(res, 201, {}) + if (req.method === "POST" && path.endsWith("/assignees")) return send(res, 200, {}) + if (req.method === "POST" && path.endsWith("/requested_reviewers")) return send(res, 200, {}) + send(res, 404, { message: "unhandled " + req.method + " " + req.url }) + }) +}) +server.listen(0, "127.0.0.1", function () { fs.writeFileSync(process.env.STUB_PORT_FILE, String(server.address().port)) }) +` + +function stubCommit(login, daysAgo) { + const at = Date.parse(SURFACE_NOW) - daysAgo * 86_400_000 + return { author: { login, type: "User" }, commit: { author: { date: new Date(at).toISOString() } } } +} - // Both calls belong to the create arm, after the PR exists. - const createIdx = src.indexOf("const pr = await api(`/repos/${repo()}/pulls`") - assert.ok(createIdx >= 0, "create-PR call must exist") - assert.ok(src.indexOf("/assignees`, {") > createIdx, "assignees POST must follow PR creation") - assert.ok(src.indexOf("/requested_reviewers`, {") > createIdx, "reviewer POST must follow PR creation") - - // A failure here must not fail the run — the PR is already open. - const ownerIdx = src.indexOf("/assignees`, {") - const tryIdx = src.lastIndexOf("try {", ownerIdx) - const catchIdx = src.indexOf("} catch", ownerIdx) - assert.ok(tryIdx >= 0 && catchIdx > src.indexOf("/requested_reviewers`, {"), "both POSTs must sit in one try/catch") +function startSurfaceStub(config) { + const dir = mktemp("docs-sync-stub-") + const portFile = path.join(dir, "port") + const logFile = path.join(dir, "requests.jsonl") + const configFile = path.join(dir, "config.json") + fs.writeFileSync(configFile, JSON.stringify(config, null, 2)) + const script = path.join(dir, "server.cjs") + fs.writeFileSync(script, SURFACE_STUB_SERVER) + const child = spawn(process.execPath, [script], { + stdio: "ignore", + env: { ...process.env, STUB_PORT_FILE: portFile, STUB_LOG_FILE: logFile, STUB_CONFIG_FILE: configFile }, + }) + let port = "" + for (let i = 0; i < 200 && !port; i++) { + if (fs.existsSync(portFile)) port = fs.readFileSync(portFile, "utf8").trim() + else sleepSync(25) + } + if (!port) { + child.kill() + throw new Error("stub API did not report a port") + } + return { dir, logFile, port, child } +} + +function readStubLog(logFile) { + if (!fs.existsSync(logFile)) return [] + return fs + .readFileSync(logFile, "utf8") + .trim() + .split("\n") + .filter(Boolean) + .map((l) => JSON.parse(l)) +} + +// Temp repo with a bare origin, main carrying base docs, and the integration +// branch checked out with the working-tree docs changes that span two surfaces +// plus one unmapped file. +function setupSurfaceRepo() { + const root = mktemp("docs-sync-surface-") + const originDir = path.join(root, "origin.git") + gitIn(root, ["init", "--bare", "origin.git"]) + const repoDir = path.join(root, "repo") + fs.mkdirSync(repoDir) + initRepoWithIdentity(repoDir) + for (const section of ["getting-started", "gateway", "community"]) { + const p = path.join(repoDir, "packages", "kilo-docs", "pages", section, "base.md") + fs.mkdirSync(path.dirname(p), { recursive: true }) + fs.writeFileSync(p, "# base\n") + } + gitIn(repoDir, ["add", "packages/kilo-docs"]) + gitIn(repoDir, ["commit", "-m", "base docs"]) + gitIn(repoDir, ["remote", "add", "origin", originDir]) + gitIn(repoDir, ["push", "-q", "origin", "main"]) + // The integration branch is local; surface branches are pushed instead. + gitIn(repoDir, ["checkout", "-q", "-b", "docs/auto-sync"]) + for (const file of Object.values(SURFACE_PAGE_FILES)) { + const p = path.join(repoDir, file) + fs.mkdirSync(path.dirname(p), { recursive: true }) + fs.writeFileSync(p, "# new\n") + } + return { root, repoDir } +} + +function runUpsert(repoDir, root, port) { + return runNodeScript(UPSERT_SCRIPT, { + cwd: repoDir, + env: { + GITHUB_REPOSITORY: "acme/repo", + GH_TOKEN: "stub-token", + DOCS_SYNC_API_BASE: `http://127.0.0.1:${port}`, + PROCESSED_THROUGH: SURFACE_NOW, + SINCE: "2026-08-01T00:00:00.000Z", + VERIFIED: "true", + BRANCH: "docs/auto-sync", + PREP_MODE: "update", + GITHUB_OUTPUT: path.join(root, "gh-output"), + GITHUB_STEP_SUMMARY: path.join(root, "gh-summary"), + }, + }) +} + +function case12_surfaceSegmentation() { + console.log("case 12 — per-surface segmentation") + const stub = startSurfaceStub({ + openPrs: [], + commits: { + "packages/opencode/": [stubCommit("alice", 1), stubCommit("bob", 5)], + "packages/kilo-vscode/": [stubCommit("erin", 1), stubCommit("frank", 3)], + "packages/kilo-gateway/": [stubCommit("carol", 2), stubCommit("dave", 4)], + }, + permissions: { alice: "write", bob: "write", erin: "write", frank: "write", carol: "write", dave: "write" }, + }) + try { + const { root, repoDir } = setupSurfaceRepo() + const result = runUpsert(repoDir, root, stub.port) + assert.equal(result.status, 0, `upsert must exit 0: ${result.output}`) + + const entries = readStubLog(stub.logFile) + const creates = entries.filter((e) => e.kind === "create") + assert.equal( + creates.length, + 4, + `exactly four PRs must be created; got ${JSON.stringify(creates)}\n${result.output}`, + ) + const byHead = new Map(creates.map((c) => [c.head, c])) + assert.deepEqual([...byHead.keys()].sort(), [ + `${SURFACE_PREFIX}cli`, + `${SURFACE_PREFIX}gateway`, + `${SURFACE_PREFIX}other`, + `${SURFACE_PREFIX}vscode`, + ]) + + const assignees = entries.filter((e) => e.method === "POST" && e.url.endsWith("/assignees")) + const reviews = entries.filter((e) => e.method === "POST" && e.url.endsWith("/requested_reviewers")) + const pairFor = (name) => { + const create = byHead.get(`${SURFACE_PREFIX}${name}`) + const a = assignees.find((e) => e.url.includes(`/issues/${create.number}/`)) + const r = reviews.find((e) => e.url.includes(`/pulls/${create.number}/`)) + assert.ok(a, `assignee POST for ${name}`) + assert.ok(r, `reviewer POST for ${name}`) + return { assignees: a.body.assignees, reviewers: r.body.reviewers } + } + assert.deepEqual(pairFor("cli"), { assignees: ["alice", "bob"], reviewers: ["alice", "bob"] }) + assert.deepEqual(pairFor("vscode"), { assignees: ["erin", "frank"], reviewers: ["erin", "frank"] }) + assert.deepEqual(pairFor("gateway"), { assignees: ["carol", "dave"], reviewers: ["carol", "dave"] }) + assert.deepEqual(pairFor("other"), { + assignees: ["lambertjosh", "intentionally-left-nil"], + reviewers: ["lambertjosh", "intentionally-left-nil"], + }) + + for (const create of creates) { + const body = create.body.body + assert.match(body, /Derived from the repository layout/, `derivation in ${create.head}`) + assert.match(body, /`packages\/kilo-docs\/pages\/community\/`/, `other paths in ${create.head}`) + assert.match(body, /\.github\/docs-sync\/surfaces\.json/, `map file in ${create.head}`) + } + assert.match(byHead.get(`${SURFACE_PREFIX}cli`).body.body, /half-life 180 days/) + assert.match(byHead.get(`${SURFACE_PREFIX}gateway`).body.body, /half-life 180 days/) + assert.match(byHead.get(`${SURFACE_PREFIX}other`).body.body, /fixed reviewers/i) + + // Each surface branch carries exactly its own changed file. + const branchFiles = new Map() + for (const name of Object.keys(SURFACE_PAGE_FILES)) { + const head = `${SURFACE_PREFIX}${name}` + const diff = gitIn(repoDir, ["diff", "--name-only", "origin/main", `origin/${head}`]) + branchFiles.set(name, diff.split("\n").filter(Boolean)) + } + for (const [name, file] of Object.entries(SURFACE_PAGE_FILES)) { + assert.deepEqual(branchFiles.get(name), [file], `branch ${name} must carry only its own file`) + } + const flat = [...branchFiles.values()].flat() + assert.equal(flat.length, 4, "four changed files total") + assert.equal(new Set(flat).size, 4, "no changed file may appear in two PRs") + + // Run-log proof for the PR body: the computed map, the two assignees per + // surface, and the titles the action would open. Deterministic; the GitHub + // API above is a local stub and no real PR or review request is created. + // The lines are asserted here too, so `node selftest.mjs` fails rather than + // silently passing if the proof stops being emitted. + const mapLines = result.output + .split("\n") + .filter((line) => line.startsWith("docs-sync surface map") || /^ {2}\S+ <- /.test(line)) + const prLines = creates.map((c) => { + const name = c.head.slice(SURFACE_PREFIX.length) + const pair = pairFor(name) + return `${c.head}: "${c.body.title}" assignees=${pair.assignees.join(",")} reviewers=${pair.reviewers.join(",")} draft=${c.body.draft}` + }) + assert.equal( + mapLines.length, + Object.keys(SURFACE_PAGE_FILES).length + 1, + `the run log must name the surface of every changed file; got ${JSON.stringify(mapLines)}`, + ) + for (const [name, file] of Object.entries(SURFACE_PAGE_FILES)) { + assert.ok(mapLines.includes(` ${name} <- ${file}`), `the run log must map ${file} to ${name}`) + } + for (const c of creates) { + assert.match( + c.body.title, + /^docs: auto-sync [a-z]+ with merged PRs \(through \d{4}-\d{2}-\d{2}\)$/, + `surface PR title: ${c.body.title}`, + ) + } + console.log(" computed surface map (file -> surface):") + for (const line of mapLines) console.log(` ${line}`) + console.log(" surface PRs it would open:") + for (const line of prLines) console.log(` ${line}`) + } finally { + stub.child.kill() + } +} + +function case13_surfaceFailureIsolated() { + console.log("case 13 — one surface's failure is isolated") + const stub = startSurfaceStub({ + openPrs: [], + failHead: `${SURFACE_PREFIX}cli`, + commits: { "packages/kilo-gateway/": [stubCommit("carol", 2), stubCommit("dave", 4)] }, + permissions: { carol: "write", dave: "write" }, + }) + try { + const { root, repoDir } = setupSurfaceRepo() + const result = runUpsert(repoDir, root, stub.port) + assert.equal(result.status, 0, `run must exit 0 when one surface fails: ${result.output}`) + assert.match(result.output, /surface cli failed/, "the failure must be warned, not thrown") + + const creates = readStubLog(stub.logFile).filter((e) => e.kind === "create") + assert.equal(creates.length, 3, "the other three surfaces must still create PRs") + assert.deepEqual([...new Set(creates.map((c) => c.head))].sort(), [ + `${SURFACE_PREFIX}gateway`, + `${SURFACE_PREFIX}other`, + `${SURFACE_PREFIX}vscode`, + ]) + } finally { + stub.child.kill() + } +} + +function case14_prepareBranchSurfaces() { + console.log("case 14 — prepare-branch merges surface branches without a ref collision") + const root = mktemp("docs-sync-prep-") + const originDir = path.join(root, "origin.git") + gitIn(root, ["init", "--bare", "origin.git"]) + const repoDir = path.join(root, "repo") + fs.mkdirSync(repoDir) + initRepoWithIdentity(repoDir) + + const write = (rel, text) => { + const p = path.join(repoDir, rel) + fs.mkdirSync(path.dirname(p), { recursive: true }) + fs.writeFileSync(p, text) + } + write("packages/kilo-docs/pages/getting-started/base.md", "# base\n") + gitIn(repoDir, ["add", "packages/kilo-docs"]) + gitIn(repoDir, ["commit", "-m", "base"]) + gitIn(repoDir, ["remote", "add", "origin", originDir]) + gitIn(repoDir, ["push", "-q", "origin", "main"]) + + // Durability integration branch. + gitIn(repoDir, ["checkout", "-q", "-b", "docs/auto-sync-integration"]) + write("packages/kilo-docs/pages/getting-started/integ.md", "# integ\n") + gitIn(repoDir, ["add", "packages/kilo-docs"]) + gitIn(repoDir, ["commit", "-m", "integ"]) + gitIn(repoDir, ["push", "-q", "origin", "docs/auto-sync-integration"]) + + // Surface branch carrying a human commit. + gitIn(repoDir, ["checkout", "-q", "main"]) + gitIn(repoDir, ["checkout", "-q", "-b", "docs/auto-sync/cli"]) + write("packages/kilo-docs/pages/getting-started/human.md", "# human\n") + gitIn(repoDir, ["add", "packages/kilo-docs"]) + gitIn(repoDir, ["commit", "-m", "human"]) + gitIn(repoDir, ["push", "-q", "origin", "docs/auto-sync/cli"]) + // actions/checkout leaves other branches as remote-tracking refs; drop the + // local surface branch so only the integration branch name is a local head. + gitIn(repoDir, ["checkout", "-q", "main"]) + gitIn(repoDir, ["branch", "-D", "docs/auto-sync/cli"]) + + const stub = startSurfaceStub({ openPrs: [{ number: 7, head: "docs/auto-sync/cli", body: "" }] }) + try { + const outputFile = path.join(root, "gh-output") + const result = runNodeScript(PREP_SCRIPT, { + cwd: repoDir, + env: { + GITHUB_REPOSITORY: "acme/repo", + GH_TOKEN: "stub-token", + DOCS_SYNC_API_BASE: `http://127.0.0.1:${stub.port}`, + GITHUB_OUTPUT: outputFile, + }, + }) + assert.equal(result.status, 0, `prepare-branch must exit 0: ${result.output}`) + const out = fs.readFileSync(outputFile, "utf8") + assert.match(out, /branch=docs\/auto-sync\n/, "integration branch output") + assert.match(out, /mode=update\n/, "mode output") + assert.ok( + fs.existsSync(path.join(repoDir, "packages/kilo-docs/pages/getting-started/human.md")), + "the surface branch's human commit must be merged into the integration tree", + ) + assert.ok( + fs.existsSync(path.join(repoDir, "packages/kilo-docs/pages/getting-started/integ.md")), + "the durability content must be present", + ) + } finally { + stub.child.kill() + } +} + +function case15_surfaceUpdate() { + console.log("case 15 — updating an open surface PR preserves human commits") + const stub = startSurfaceStub({ + openPrs: [ + { + number: 42, + head: `${SURFACE_PREFIX}cli`, + body: "old body\n\n| old change | [acme#1](https://example.test/1) |\n\n", + }, + ], + commits: { + "packages/opencode/": [stubCommit("alice", 1), stubCommit("bob", 5)], + "packages/kilo-gateway/": [stubCommit("carol", 2), stubCommit("dave", 4)], + }, + permissions: { alice: "write", bob: "write", carol: "write", dave: "write" }, + }) + try { + const { root, repoDir } = setupSurfaceRepo() + + // A human commit on the open cli PR branch. + gitIn(repoDir, ["checkout", "-q", "-b", "human-tmp", "origin/main"]) + const human = path.join(repoDir, "packages/kilo-docs/pages/getting-started/human.md") + fs.mkdirSync(path.dirname(human), { recursive: true }) + fs.writeFileSync(human, "# human\n") + gitIn(repoDir, ["add", "packages/kilo-docs/pages/getting-started/human.md"]) + gitIn(repoDir, ["commit", "-m", "human edit"]) + gitIn(repoDir, ["push", "-q", "origin", "HEAD:refs/heads/docs/auto-sync/cli"]) + gitIn(repoDir, ["checkout", "-q", "docs/auto-sync"]) + gitIn(repoDir, ["branch", "-D", "human-tmp"]) + + const result = runUpsert(repoDir, root, stub.port) + assert.equal(result.status, 0, `upsert must exit 0: ${result.output}`) + + const entries = readStubLog(stub.logFile) + const creates = entries.filter((e) => e.kind === "create") + assert.equal(creates.length, 3, "only the three other surfaces are created") + assert.ok(!creates.some((c) => c.head === `${SURFACE_PREFIX}cli`), "cli must be updated, not re-created") + const patched = entries.find((e) => e.method === "PATCH" && e.url.includes("/pulls/42")) + assert.ok(patched, "the open cli PR must be PATCHed") + assert.match(patched.body.body, /old change/, "existing rows must be carried forward") + + // The updated cli branch keeps the human commit and adds the new cli file. + const diff = gitIn(repoDir, ["diff", "--name-only", "origin/main", `origin/${SURFACE_PREFIX}cli`]) + assert.deepEqual(diff.split("\n").filter(Boolean).sort(), [ + "packages/kilo-docs/pages/getting-started/human.md", + "packages/kilo-docs/pages/getting-started/new.md", + ]) + } finally { + stub.child.kill() + } +} + +function case16_surfaceDeletion() { + console.log("case 16 — a deleted doc file reaches its surface PR") + const stub = startSurfaceStub({ + openPrs: [], + commits: { + "packages/opencode/": [stubCommit("alice", 1)], + "packages/kilo-gateway/": [stubCommit("carol", 2), stubCommit("dave", 4)], + }, + permissions: { alice: "write", carol: "write", dave: "write" }, + }) + try { + const { root, repoDir } = setupSurfaceRepo() + // The integration tree removes a page that still exists on origin/main. + const gone = path.join(repoDir, "packages/kilo-docs/pages/gateway/base.md") + assert.ok(fs.existsSync(gone), "fixture must start with the gateway base page") + fs.rmSync(gone) + gitIn(repoDir, ["rm", "-q", "packages/kilo-docs/pages/gateway/base.md"]) + gitIn(repoDir, ["commit", "-m", "remove gateway base page"]) + + const result = runUpsert(repoDir, root, stub.port) + assert.equal(result.status, 0, `upsert must exit 0 with a deletion: ${result.output}`) + assert.ok(!/surface gateway failed/.test(result.output), `a deletion must not fail its surface: ${result.output}`) + + const creates = readStubLog(stub.logFile).filter((e) => e.kind === "create") + const gateway = creates.find((c) => c.head === `${SURFACE_PREFIX}gateway`) + assert.ok(gateway, "the gateway surface PR must still be created") + + const status = gitIn(repoDir, ["diff", "--name-status", "origin/main", `origin/${SURFACE_PREFIX}gateway`]) + const lines = status.split("\n").filter(Boolean) + assert.ok( + lines.includes("D\tpackages/kilo-docs/pages/gateway/base.md"), + `the gateway branch must carry the deletion; got:\n${status}`, + ) + assert.ok( + lines.includes("A\tpackages/kilo-docs/pages/gateway/new.md"), + `the gateway branch must still add its new page; got:\n${status}`, + ) + } finally { + stub.child.kill() + } } // --------------------------------------------------------------------------- @@ -3475,6 +3943,11 @@ function main() { case9_reverts, case10_learnings, case11_prOwner, + case12_surfaceSegmentation, + case13_surfaceFailureIsolated, + case14_prepareBranchSurfaces, + case15_surfaceUpdate, + case16_surfaceDeletion, ] let failed = 0 for (const fn of cases) { diff --git a/.github/docs-sync/surfaces.json b/.github/docs-sync/surfaces.json new file mode 100644 index 000000000000..ed0f8b429015 --- /dev/null +++ b/.github/docs-sync/surfaces.json @@ -0,0 +1,52 @@ +{ + "derivation": "Derived from the repository layout. A product surface is a package under packages/ that ships a distinct client, plugin, backend, or hosted service: cli = packages/opencode/ + packages/tui/ + packages/server/ + packages/sdk/ + packages/plugin/; vscode = packages/kilo-vscode/ + packages/kilo-web-ui/ + packages/kilo-ui/; jetbrains = packages/kilo-jetbrains/; gateway = packages/kilo-gateway/; web = packages/kilo-console/ + packages/kilo-indexing/ + packages/kilo-memory/ + packages/kilo-sandbox/. Docs route from the IA tree packages/kilo-docs/pages/ plus docs/jetbrains-vscode-settings-parity.md: each surface lists the pages sections that document it, and the per-platform pages under packages/kilo-docs/pages/code-with-ai/platforms/ map to the matching extension surface (the vscode/ directory to vscode, jetbrains.md to jetbrains). A doc path belongs to the surface with the longest matching prefix; a path that matches none of those prefixes falls to `other` (the explicit other prefixes are listed under other.docs).", + "other": { + "name": "other", + "reviewers": ["lambertjosh", "intentionally-left-nil"], + "docs": [ + "packages/kilo-docs/pages/community/", + "packages/kilo-docs/pages/kiloclaw/", + "packages/kilo-docs/pages/contributing/", + "packages/kilo-docs/LEARNINGS.md", + "docs/" + ] + }, + "surfaces": [ + { + "name": "cli", + "sources": ["packages/opencode/", "packages/tui/", "packages/server/", "packages/sdk/", "packages/plugin/"], + "docs": [ + "packages/kilo-docs/pages/getting-started/", + "packages/kilo-docs/pages/code-with-ai/", + "packages/kilo-docs/pages/customize/", + "packages/kilo-docs/pages/automate/", + "packages/kilo-docs/pages/collaborate/" + ] + }, + { + "name": "vscode", + "sources": ["packages/kilo-vscode/", "packages/kilo-web-ui/", "packages/kilo-ui/"], + "docs": ["packages/kilo-docs/pages/code-with-ai/platforms/vscode/"] + }, + { + "name": "jetbrains", + "sources": ["packages/kilo-jetbrains/"], + "docs": ["packages/kilo-docs/pages/code-with-ai/platforms/jetbrains.md"] + }, + { + "name": "gateway", + "sources": ["packages/kilo-gateway/"], + "docs": ["packages/kilo-docs/pages/gateway/", "packages/kilo-docs/pages/ai-providers/"] + }, + { + "name": "web", + "sources": [ + "packages/kilo-console/", + "packages/kilo-indexing/", + "packages/kilo-memory/", + "packages/kilo-sandbox/" + ], + "docs": ["packages/kilo-docs/pages/api/", "packages/kilo-docs/pages/deploy-secure/"] + } + ] +} diff --git a/.github/docs-sync/surfaces.mjs b/.github/docs-sync/surfaces.mjs new file mode 100644 index 000000000000..428bbbb10b15 --- /dev/null +++ b/.github/docs-sync/surfaces.mjs @@ -0,0 +1,151 @@ +// kilocode_change - new file + +/** + * Committed product-surface map for the docs-sync bot. + * + * The surfaces are data, not a list hard-coded in a function: this module only + * knows how to read `.github/docs-sync/surfaces.json` and answer which surface + * a doc path or source path belongs to. Edit the JSON to change the map. + * + * A doc path belongs to at most one surface. `surfaceForDoc` and + * `surfaceForSource` therefore return exactly one name: the surface whose + * configured prefix is the longest match (ties broken by file order in + * `surfaces.json`), or `other` when nothing matches. Longest-match lets a + * specific prefix (the per-platform `code-with-ai/platforms/vscode/` pages) + * beat the broad `code-with-ai/` prefix owned by `cli`. `other` is always last + * in `surfaceNames` so PR creation/reporting is deterministic. + */ + +import fs from "node:fs" +import path from "node:path" +import { fileURLToPath } from "node:url" + +const HERE = path.dirname(fileURLToPath(import.meta.url)) + +/** Absolute path to the committed surface map. */ +export const SURFACE_MAP_PATH = path.join(HERE, "surfaces.json") + +/** Name of the catch-all surface. */ +export const OTHER = "other" + +/** + * Branch prefix for a per-surface docs-sync PR. The rolling integration branch + * is exactly `docs/auto-sync` (no trailing segment) and has no PR of its own, + * so a head that starts with this prefix is a product/`other` surface PR. + */ +export const SURFACE_BRANCH_PREFIX = "docs/auto-sync/" + +/** Branch name for a surface PR. */ +export function surfaceBranch(name) { + return `${SURFACE_BRANCH_PREFIX}${name}` +} + +/** The prefix that marks a per-surface branch. */ +export function surfaceBranchPrefix() { + return SURFACE_BRANCH_PREFIX +} + +/** Normalize a repo-relative path for prefix matching. */ +function norm(file) { + return String(file ?? "") + .replace(/\\/g, "/") + .replace(/^\.\//, "") + .replace(/^\/+/, "") +} + +function names(map) { + return [...(map?.surfaces ?? []).map((s) => s.name), map?.other?.name ?? OTHER] +} + +/** + * Load the surface map from JSON. Defaults to the committed map; the `file` + * argument exists so tests can point at a copy. + */ +export function loadSurfaceMap(file = SURFACE_MAP_PATH) { + return JSON.parse(fs.readFileSync(file, "utf8")) +} + +/** Surface names in file order with `other` last. */ +export function surfaceNames(map) { + return names(map) +} + +/** Doc path prefixes assigned to `name` (empty for `other` unless configured). */ +export function surfaceDocPrefixes(name, map) { + if (name === (map?.other?.name ?? OTHER)) return map?.other?.docs ?? [] + return (map?.surfaces ?? []).find((s) => s.name === name)?.docs ?? [] +} + +/** Source path prefixes used for git-history ranking (empty for `other`). */ +export function surfaceSourcePrefixes(name, map) { + if (name === (map?.other?.name ?? OTHER)) return [] + return (map?.surfaces ?? []).find((s) => s.name === name)?.sources ?? [] +} + +/** Configured reviewers for `name`; product surfaces compute theirs at runtime. */ +export function surfaceReviewers(name, map) { + if (name === (map?.other?.name ?? OTHER)) return map?.other?.reviewers ?? [] + return (map?.surfaces ?? []).find((s) => s.name === name)?.reviewers ?? [] +} + +/** The explicit doc prefixes that fall to `other`, for printing in PR bodies. */ +export function otherDocPrefixes(map) { + return map?.other?.docs ?? [] +} + +/** The derivation sentence, for printing in PR bodies. */ +export function derivation(map) { + return map?.derivation ?? "" +} + +/** + * Exactly one surface name for a path: the surface whose configured prefix in + * `key` ("docs" or "sources") is the longest match, ties broken by surface + * order in the map, falling back to `other` when no prefix matches. + */ +function longestMatch(file, map, key) { + const f = norm(file) + let name = map?.other?.name ?? OTHER + let len = -1 + for (const s of map?.surfaces ?? []) { + for (const prefix of s[key] ?? []) { + const p = norm(prefix) + if (p.length > len && f.startsWith(p)) { + name = s.name + len = p.length + } + } + } + return name +} + +/** Exactly one surface name for a doc path. */ +export function surfaceForDoc(file, map = loadSurfaceMap()) { + return longestMatch(file, map, "docs") +} + +/** Exactly one surface name for a source path. */ +export function surfaceForSource(file, map = loadSurfaceMap()) { + return longestMatch(file, map, "sources") +} + +/** + * Group files by the surface their doc path belongs to. Preserves input order + * inside each group; groups are ordered by `surfaceNames` (file order, `other` + * last). Empty groups are omitted. + */ +export function groupBySurface(files, map) { + const order = surfaceNames(map) + const buckets = new Map(order.map((name) => [name, []])) + for (const file of Array.isArray(files) ? files : []) { + const name = surfaceForDoc(file, map) + const bucket = buckets.get(name) + if (bucket) bucket.push(file) + } + const out = new Map() + for (const name of order) { + const bucket = buckets.get(name) + if (bucket && bucket.length > 0) out.set(name, bucket) + } + return out +} diff --git a/.github/docs-sync/surfaces.test.mjs b/.github/docs-sync/surfaces.test.mjs new file mode 100644 index 000000000000..ad8c32d6415a --- /dev/null +++ b/.github/docs-sync/surfaces.test.mjs @@ -0,0 +1,258 @@ +// kilocode_change - new file + +/** + * Unit tests for the docs-sync surface map and reviewer ranking. + * + * Fixtures are created in a temp dir at runtime (no committed fixtures) so the + * tests prove the map is data-driven rather than baked into code. + * Run: node .github/docs-sync/surfaces.test.mjs + */ + +import assert from "node:assert/strict" +import fs from "node:fs" +import os from "node:os" +import path from "node:path" +import test, { after } from "node:test" + +import { + SURFACE_MAP_PATH, + OTHER, + loadSurfaceMap, + surfaceNames, + surfaceForDoc, + surfaceForSource, + surfaceDocPrefixes, + surfaceSourcePrefixes, + surfaceReviewers, + otherDocPrefixes, + derivation, + groupBySurface, +} from "./surfaces.mjs" +import { rankContributors, computeSurfaceReviewers } from "./reviewers.mjs" + +const temps = [] + +function writeMap(map) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "docs-sync-surfaces-")) + temps.push(dir) + const file = path.join(dir, "surfaces.json") + fs.writeFileSync(file, JSON.stringify(map, null, 2)) + return file +} + +after(() => { + for (const dir of temps.splice(0)) fs.rmSync(dir, { recursive: true, force: true }) +}) + +const map = loadSurfaceMap() +const NOW = Date.parse("2026-06-01T00:00:00Z") +const daysAgo = (n) => new Date(NOW - n * 86_400_000).toISOString() + +test("committed map is well formed", () => { + assert.ok(fs.existsSync(SURFACE_MAP_PATH)) + assert.equal(path.basename(SURFACE_MAP_PATH), "surfaces.json") + assert.ok(derivation(map).length > 0) + assert.equal(surfaceNames(map).at(-1), OTHER) + assert.deepEqual(surfaceNames(map), ["cli", "vscode", "jetbrains", "gateway", "web", "other"]) + assert.deepEqual(surfaceReviewers(OTHER, map), ["lambertjosh", "intentionally-left-nil"]) +}) + +test("every configured prefix resolves to exactly its own surface", () => { + for (const surface of map.surfaces) { + for (const prefix of surfaceDocPrefixes(surface.name, map)) { + assert.equal(surfaceForDoc(`${prefix}index.md`, map), surface.name, `doc ${prefix}`) + assert.equal(surfaceForDoc(`${prefix}nested/deep.md`, map), surface.name, `doc ${prefix}`) + } + for (const prefix of surfaceSourcePrefixes(surface.name, map)) { + assert.equal(surfaceForSource(`${prefix}src/index.ts`, map), surface.name, `source ${prefix}`) + } + } +}) + +test("platform pages route to the matching extension surface", () => { + assert.equal(surfaceForDoc("packages/kilo-docs/pages/code-with-ai/platforms/vscode/index.md", map), "vscode") + assert.equal(surfaceForDoc("packages/kilo-docs/pages/code-with-ai/platforms/vscode/whats-new.md", map), "vscode") + assert.equal(surfaceForDoc("packages/kilo-docs/pages/code-with-ai/platforms/jetbrains.md", map), "jetbrains") + assert.equal(surfaceForDoc("packages/kilo-docs/pages/code-with-ai/platforms/cli.md", map), "cli") + assert.equal(surfaceForDoc("packages/kilo-docs/pages/code-with-ai/features/autocomplete.md", map), "cli") + // Longest prefix wins: the specific platform prefix beats the broad cli one. + assert.equal(surfaceForDoc("packages/kilo-docs/pages/code-with-ai/platforms/vscode/nested/deep.md", map), "vscode") +}) + +test("unmatched doc and source paths fall to other", () => { + assert.equal(surfaceForDoc("packages/kilo-docs/pages/community/index.md", map), OTHER) + assert.equal(surfaceForDoc("packages/kilo-docs/pages/kiloclaw/index.md", map), OTHER) + assert.equal(surfaceForDoc("packages/kilo-docs/pages/contributing/index.md", map), OTHER) + assert.equal(surfaceForDoc("packages/kilo-docs/LEARNINGS.md", map), OTHER) + assert.equal(surfaceForDoc("packages/kilo-docs/pages/never-seen/new.md", map), OTHER) + assert.equal(surfaceForDoc("packages/kilo-docs/pages/index.tsx", map), OTHER) + assert.equal(surfaceForDoc("docs/jetbrains-vscode-settings-parity.md", map), OTHER) + assert.equal(surfaceForSource("packages/kilo-telemetry/src/index.ts", map), OTHER) + assert.equal(surfaceForDoc("README.md", map), OTHER) + + assert.ok(otherDocPrefixes(map).includes("packages/kilo-docs/pages/community/")) + assert.ok(otherDocPrefixes(map).includes("docs/")) +}) + +test("the map is read from data: changing a prefix changes the answer", () => { + assert.equal(surfaceForDoc("packages/kilo-docs/pages/getting-started/index.md", map), "cli") + + const changed = structuredClone(map) + const cli = changed.surfaces.find((s) => s.name === "cli") + cli.docs = cli.docs.map((p) => + p === "packages/kilo-docs/pages/getting-started/" ? "packages/kilo-docs/pages/renamed/" : p, + ) + const changedFile = loadSurfaceMap(writeMap(changed)) + assert.equal(surfaceForDoc("packages/kilo-docs/pages/getting-started/index.md", changedFile), OTHER) + assert.equal(surfaceForDoc("packages/kilo-docs/pages/renamed/index.md", changedFile), "cli") + + const extended = structuredClone(map) + extended.surfaces.push({ + name: "extra", + sources: ["packages/kilo-extra/"], + docs: ["packages/kilo-docs/pages/extra/"], + }) + const extendedFile = loadSurfaceMap(writeMap(extended)) + assert.equal(surfaceForDoc("packages/kilo-docs/pages/extra/index.md", extendedFile), "extra") + assert.equal(surfaceForSource("packages/kilo-extra/src/index.ts", extendedFile), "extra") + assert.equal(surfaceForDoc("packages/kilo-docs/pages/extra/index.md", map), OTHER) +}) + +test("groupBySurface partitions files with no duplicates in deterministic order", () => { + const files = [ + "packages/kilo-docs/pages/community/index.md", + "packages/kilo-docs/pages/gateway/index.md", + "packages/kilo-docs/pages/getting-started/index.md", + "packages/kilo-docs/pages/unknown/a.md", + ] + const groups = groupBySurface(files, map) + assert.deepEqual([...groups.keys()], ["cli", "gateway", "other"]) + assert.deepEqual(groups.get("other"), [ + "packages/kilo-docs/pages/community/index.md", + "packages/kilo-docs/pages/unknown/a.md", + ]) + assert.deepEqual(groups.get("cli"), ["packages/kilo-docs/pages/getting-started/index.md"]) + const flat = [...groups.values()].flat() + assert.equal(flat.length, files.length) + assert.equal(new Set(flat).size, files.length) +}) + +test("rankContributors weights recent over old and applies the half-life", () => { + const commits = [ + { login: "recent", type: "User", date: daysAgo(10) }, + ...Array.from({ length: 4 }, () => ({ login: "old", type: "User", date: daysAgo(1500) })), + { login: "dependabot[bot]", type: "Bot", date: daysAgo(1) }, + { login: "renovate-bot", type: "Bot", date: daysAgo(1) }, + { login: "human-bot[bot]", type: "User", date: daysAgo(1) }, + ] + const ranked = rankContributors(commits, NOW) + assert.deepEqual( + ranked.map((r) => r.login), + ["recent", "old"], + ) + assert.equal(ranked[0].count, 1) + assert.equal(ranked[1].count, 4) + assert.ok(ranked[0].score > ranked[1].score) + + const fresh = rankContributors([{ login: "x", type: "User", date: daysAgo(0) }], NOW) + assert.ok(Math.abs(fresh[0].score - 1) < 1e-9) + const halved = rankContributors([{ login: "x", type: "User", date: daysAgo(180) }], NOW) + assert.ok(Math.abs(halved[0].score - 0.5) < 1e-9) + const faster = rankContributors([{ login: "x", type: "User", date: daysAgo(30) }], NOW, { halfLifeDays: 30 }) + assert.ok(Math.abs(faster[0].score - 0.5) < 1e-9) +}) + +test("rankContributors orders deterministically on score ties", () => { + const ranked = rankContributors( + [ + { login: "bob", type: "User", date: daysAgo(5) }, + { login: "alice", type: "User", date: daysAgo(5) }, + { login: "carol", type: "User", date: daysAgo(5) }, + ], + NOW, + ) + assert.deepEqual( + ranked.map((r) => r.login), + ["alice", "bob", "carol"], + ) +}) + +test("other reviewers are the fixed pair and never hit the API", async () => { + let calls = 0 + const result = await computeSurfaceReviewers(OTHER, { + api: async () => { + calls++ + return [] + }, + repo: "acme/kilo", + now: NOW, + map, + }) + assert.deepEqual(result.reviewers, ["lambertjosh", "intentionally-left-nil"]) + assert.equal(calls, 0) + assert.deepEqual(result.sourcePrefixes, []) + assert.match(result.note, /fixed reviewers/) +}) + +test("product-surface reviewers come from ranked commits that have write access", async () => { + const seen = [] + const api = async (p) => { + seen.push(p) + if (p.includes("/commits?")) { + return [ + { author: { login: "zoe", type: "User" }, commit: { author: { date: daysAgo(2) } } }, + { author: { login: "bob", type: "User" }, commit: { author: { date: daysAgo(3) } } }, + { author: { login: "nope", type: "User" }, commit: { author: { date: daysAgo(4) } } }, + { author: { login: "carl", type: "User" }, commit: { author: { date: daysAgo(5) } } }, + { author: { login: "dependabot[bot]", type: "Bot" }, commit: { author: { date: daysAgo(1) } } }, + ] + } + if (p.endsWith("/collaborators/zoe/permission")) return { permission: "read" } + if (p.endsWith("/collaborators/bob/permission")) return { permission: "write" } + if (p.endsWith("/collaborators/nope/permission")) return { permission: "none" } + if (p.endsWith("/collaborators/carl/permission")) return { role_name: "admin" } + throw new Error(`unexpected ${p}`) + } + + const result = await computeSurfaceReviewers("gateway", { api, repo: "acme/kilo", now: NOW, map }) + assert.deepEqual(result.reviewers, ["bob", "carl"]) + assert.deepEqual(result.sourcePrefixes, surfaceSourcePrefixes("gateway", map)) + assert.ok(seen.some((p) => p === "/repos/acme/kilo/commits?path=packages%2Fkilo-gateway%2F&per_page=100")) + assert.match(result.note, /packages\/kilo-gateway\//) + assert.match(result.note, /half-life 180 days/) + assert.match(result.note, /write/) +}) + +test("a 404 permission skips the candidate and keeps walking", async () => { + const api = async (p) => { + if (p.includes("/commits?")) { + return [ + { author: { login: "ghost", type: "User" }, commit: { author: { date: daysAgo(1) } } }, + { author: { login: "real", type: "User" }, commit: { author: { date: daysAgo(2) } } }, + ] + } + if (p.includes("/ghost/")) { + const err = new Error("Not Found") + err.status = 404 + throw err + } + return { permission: "write" } + } + const result = await computeSurfaceReviewers("cli", { api, repo: "acme/kilo", now: NOW, map }) + assert.deepEqual(result.reviewers, ["real"]) +}) + +test("API or token failure yields no reviewers and a named reason", async () => { + const api = async () => { + throw new Error("GH_TOKEN (or GITHUB_TOKEN) is required") + } + const result = await computeSurfaceReviewers("gateway", { api, repo: "acme/kilo", now: NOW, map }) + assert.deepEqual(result.reviewers, []) + assert.match(result.note, /GH_TOKEN/) +}) + +test("a missing repository is named instead of guessed", async () => { + const result = await computeSurfaceReviewers("gateway", { api: async () => [], map }) + assert.deepEqual(result.reviewers, []) + assert.match(result.note, /GITHUB_REPOSITORY/) +}) diff --git a/.github/docs-sync/triage.mjs b/.github/docs-sync/triage.mjs index eb680846510f..87b2fde8e3a5 100644 --- a/.github/docs-sync/triage.mjs +++ b/.github/docs-sync/triage.mjs @@ -11,7 +11,8 @@ * docs_worthy:false so filter-worthy excludes it) so the watermark holds * back and the next run re-collects those PRs. * - * Env: TRIAGE_MODEL (provider/model), KILO_API_KEY + KILO_ORG_ID (gateway auth, set by + * Env: TRIAGE_MODEL (provider/model), DOCS_SYNC_VARIANT (reasoning effort, default max), + * KILO_API_KEY + KILO_ORG_ID (gateway auth, set by * the workflow; the kilo provider reads them natively). Reads the prompt from triage-prompt.md next to this script. * Budget: TRIAGE_BUDGET_MINUTES (default 35). Test hook: DOCS_SYNC_BACKOFF_MS. */ @@ -20,7 +21,15 @@ import fs from "node:fs" import path from "node:path" import { fileURLToPath } from "node:url" import { parseTriageEntries } from "./extract-json.mjs" -import { appendSummary, backoffMsForAttempt, deadline, remainingMs, runKilo, sleepSync } from "./lib.mjs" +import { + appendSummary, + backoffMsForAttempt, + deadline, + remainingMs, + REASONING_VARIANT, + runKilo, + sleepSync, +} from "./lib.mjs" import { readLearningsBlock } from "./learn.mjs" const CHUNK_SIZE = 25 @@ -78,7 +87,19 @@ function triageChunk(chunk, index, budgetDeadline) { // permission.bash map via KILO_CONFIG_CONTENT should replace --auto once the // required shell patterns are stable (see PR #12605 review thread). const result = runKilo({ - args: ["run", "--auto", prompt, "-m", model, "--dir", process.cwd(), "-f", chunkFile], + args: [ + "run", + "--auto", + prompt, + "-m", + model, + "--variant", + REASONING_VARIANT, + "--dir", + process.cwd(), + "-f", + chunkFile, + ], timeoutMs: Math.min(CHUNK_TIMEOUT_MS, left), streamStdout: false, label: `triage chunk ${index} attempt ${attempt}`, diff --git a/.github/docs-sync/upsert-pr.mjs b/.github/docs-sync/upsert-pr.mjs index d4b7d2a21fc9..8e669157018d 100644 --- a/.github/docs-sync/upsert-pr.mjs +++ b/.github/docs-sync/upsert-pr.mjs @@ -1,13 +1,16 @@ // 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. + * Commits the agent's packages/kilo-docs changes, pushes the rolling + * integration branch, and maintains one auto-docs PR per product surface + * (plus one `other` 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. + * The integration branch (`BRANCH`, normally `docs/auto-sync`) accumulates the + * whole docs tree and has no PR of its own. Every surface PR is rebuilt in a + * throwaway worktree that carries only that surface's changed files, so a file + * belongs to exactly one PR. Per-surface git and API work is caught: one + * surface's failure warns and continues and cannot lose another surface's + * changes or stall the watermark. * * Watermark invariant: processed-through never moves past a PR that has no * terminal outcome. Terminal := action !== "pending" (a deliberate agent @@ -20,22 +23,42 @@ import { execFileSync } from "node:child_process" import fs from "node:fs" +import os from "node:os" +import path from "node:path" import { pathToFileURL } from "node:url" +import { + OTHER, + derivation, + groupBySurface, + loadSurfaceMap, + otherDocPrefixes, + surfaceBranch, + surfaceDocPrefixes, + surfaceForDoc, + surfaceNames, + surfaceSourcePrefixes, +} from "./surfaces.mjs" +import { computeSurfaceReviewers } from "./reviewers.mjs" + +export { surfaceBranch } const BRANCH = process.env.BRANCH || "docs/auto-sync" +// Git refs cannot hold both `docs/auto-sync` and `docs/auto-sync/`: a +// ref may not be a path prefix of another ref. Surface PRs therefore use the +// required `docs/auto-sync/` heads, and the integration tree is pushed +// to this sibling ref purely for durability (a failed surface cannot lose the +// run's changes). +const INTEGRATION_BRANCH = process.env.INTEGRATION_BRANCH || "docs/auto-sync-integration" const FILE_CAP = 15 const ROW_CAP = 150 const PENDING_DISPLAY_CAP = 60 const SUMMARY_FILE = ".docs-sync-summary.json" -// Owner of the rolling docs PR: assigned and asked for review on creation. -const DOCS_OWNER = "emilieschario" const DOCS_PATH = "packages/kilo-docs" +const MAP_FILE = ".github/docs-sync/surfaces.json" export const LEARNINGS_FILE = "packages/kilo-docs/LEARNINGS.md" -const git = (args) => - execFileSync("git", args, { stdio: ["ignore", "pipe", "inherit"] }) - .toString() - .trim() +const git = (args, cwd) => + execFileSync("git", args, { cwd, 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 @@ -100,6 +123,7 @@ export function renderBody({ verified, draftReasons, note, + surfaceBlock = "", }) { const pendingDisplay = pendingRows.length > PENDING_DISPLAY_CAP @@ -113,7 +137,7 @@ This PR keeps kilo.ai/docs in sync with features merged to [Kilo-Org/cloud](http - 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 +${surfaceBlock ? `${surfaceBlock}\n\n` : ""}### Changes ${section("changes", "| Docs change | Source |", changesRows)} @@ -361,17 +385,245 @@ export function noDiffReport({ uncovered, sinceOverride }) { return { summary, warning } } +/** PR title for a surface. */ +export function prTitle(name, date) { + return `docs: auto-sync ${name} with merged PRs (through ${date})` +} + +/** + * The per-surface block appended to the rolling body. It states the surface, + * the derivation and the map file, the source/doc prefixes, the paths that fall + * to `other`, and how the two reviewers were computed (or why they were not). + */ +export function surfaceBlock({ name, map, reviewers, note }) { + const other = map?.other?.name ?? OTHER + const sources = surfaceSourcePrefixes(name, map) + const docs = surfaceDocPrefixes(name, map) + const otherPaths = otherDocPrefixes(map) + const list = (prefixes) => (prefixes.length > 0 ? prefixes.map((p) => `\`${p}\``).join(", ") : "_none_") + const pair = reviewers.length > 0 ? reviewers.map((r) => `@${r}`).join(" and ") : "_none computed_" + return [ + `### Surface: \`${name}\``, + "", + `- Assignees / requested reviewers: ${pair}`, + `- Derivation: ${derivation(map)}`, + `- Map: \`${MAP_FILE}\``, + `- Source prefixes: ${list(sources)}`, + `- Doc prefixes: ${list(docs)}`, + `- Paths that fall to \`${other}\`: ${list(otherPaths)}`, + `- How the two were computed: ${note}`, + ].join("\n") +} + +/** + * Open auto-docs PRs keyed by surface name. Only `docs/auto-sync/` + * heads are considered; the legacy `docs/auto-sync` head is not a surface PR. + */ +async function openSurfacePrs({ api, searchIssues, repo, names }) { + const prs = await searchIssues(`repo:${repo} is:pr is:open label:auto-docs`, { maxPages: 2 }) + const byName = new Map() + for (const item of prs) { + let detail + try { + detail = await api(`/repos/${repo}/pulls/${item.number}`) + } catch (err) { + console.warn(`::warning::docs-sync: could not read auto-docs PR #${item.number}: ${err.message}`) + continue + } + const ref = String(detail?.head?.ref ?? "") + for (const name of names) { + if (ref === surfaceBranch(name)) byName.set(name, detail) + } + } + return byName +} + +/** + * Split a surface's changed files by whether they still exist at + * `integrationSha`. A path deleted in the integration tree has no blob there, + * so `git checkout integrationSha -- ` fails with `pathspec ... did not + * match any file(s) known to git`, and checkout can only restore paths, never + * remove one. Deletions must be removed from the worktree instead. + */ +function partitionByPresence(git, sha, files) { + if (files.length === 0) return { present: [], removed: [] } + const names = new Set(git(["ls-tree", "-r", "--name-only", sha, "--", ...files]).split("\n").filter(Boolean)) + return { present: files.filter((f) => names.has(f)), removed: files.filter((f) => !names.has(f)) } +} + +/** + * Build the surface branch in a throwaway worktree and push it. On update the + * base is the open PR's remote branch (so human commits survive), merged with + * origin/main; otherwise it is a fresh branch from origin/main. + */ +function buildSurfaceBranch({ name, files, branch, integrationSha, update, date }) { + try { + git(["fetch", "origin", `+refs/heads/${branch}:refs/remotes/origin/${branch}`]) + } catch { + // Branch may not exist yet; --force-with-lease will create it. + } + const base = update ? `origin/${branch}` : "origin/main" + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "docs-sync-wt-")) + try { + git(["worktree", "add", "--detach", tmp, base]) + if (update) { + // Preserve human commits on the open PR branch. + git(["merge", "origin/main", "--no-edit"], tmp) + } + // Remove deletions before restoring the rest, so a file→directory change + // cannot block the checkout of a path under the removed file. + const { present, removed } = partitionByPresence(git, integrationSha, files) + if (removed.length > 0) git(["rm", "-rf", "--ignore-unmatch", "--", ...removed], tmp) + if (present.length > 0) { + git(["checkout", integrationSha, "--", ...present], tmp) + git(["add", "--", ...present], tmp) + } + const dirty = git(["status", "--porcelain"], tmp) + if (dirty !== "") { + git(["commit", "-m", `docs: sync ${name} with merged PRs (${date})`], tmp) + } else { + console.log(`surface ${name}: no file delta over ${base}; refreshing PR body only`) + } + git( + update + ? ["push", "origin", `HEAD:refs/heads/${branch}`] + : ["push", "--force-with-lease", "origin", `HEAD:refs/heads/${branch}`], + tmp, + ) + } finally { + try { + git(["worktree", "remove", "--force", tmp]) + } catch (err) { + console.warn(`::warning::docs-sync: could not remove worktree ${tmp}: ${err.message}`) + } + try { + fs.rmSync(tmp, { recursive: true, force: true }) + } catch (err) { + console.warn(`::warning::docs-sync: could not delete worktree dir ${tmp}: ${err.message}`) + } + try { + git(["worktree", "prune"]) + } catch (err) { + console.warn(`::warning::docs-sync: could not prune worktrees: ${err.message}`) + } + } +} + +/** Create or update one surface PR. Throws on failure; the caller isolates it. */ +async function upsertSurface({ + name, + files, + map, + api, + repo, + now, + date, + since, + through, + verified, + rows, + openPr, + integrationSha, +}) { + const branch = surfaceBranch(name) + const reviewed = await computeSurfaceReviewers(name, { api, repo, now, map }) + + const draftReasons = [] + if (files.length > FILE_CAP) draftReasons.push(`diff exceeds ${FILE_CAP} files (${files.length})`) + if (!verified) draftReasons.push("docs build/tests not passing") + const nonContent = nonContentFiles(files) + if (nonContent.length > 0) { + 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 + + const update = Boolean(openPr) + buildSurfaceBranch({ name, files, branch, integrationSha, update, date }) + + // Append-only: carry rows already on the surface PR body forward. + let oldChanges = [] + let oldSkipped = [] + let prBody = "" + if (update) { + prBody = openPr.body ?? "" + oldChanges = extractSectionRows(prBody, "changes") + oldSkipped = dropLegacySkipped(extractSectionRows(prBody, "skipped")) + } + const learnedThrough = resolveLearnedThrough({ envValue: process.env.LEARNED_THROUGH, prBody }) + + const body = renderBody({ + date, + since, + through, + learnedThrough, + changesRows: mergeRows(oldChanges, rows.changesRows), + pendingRows: rows.pendingRows, + skippedRows: mergeRows(oldSkipped, rows.skippedRows), + verified, + draftReasons, + note: "", + surfaceBlock: surfaceBlock({ name, map, reviewers: reviewed.reviewers, note: reviewed.note }), + }) + console.log(`--- ${name} PR body ---\n${body}\n--- end ${name} PR body ---`) + + let pr + if (update) { + pr = await api(`/repos/${repo}/pulls/${openPr.number}`, { + method: "PATCH", + body: { title: prTitle(name, date), body }, + }) + } else { + pr = await api(`/repos/${repo}/pulls`, { + method: "POST", + body: { title: prTitle(name, date), head: branch, base: "main", body, draft }, + }) + await api(`/repos/${repo}/issues/${pr.number}/labels`, { method: "POST", body: { labels: ["auto-docs"] } }) + } + // Best effort: the PR already exists here, so a non-collaborator, an author + // login, or a revoked account must not fail the surface. + if (reviewed.reviewers.length > 0) { + try { + await api(`/repos/${repo}/issues/${pr.number}/assignees`, { + method: "POST", + body: { assignees: reviewed.reviewers }, + }) + await api(`/repos/${repo}/pulls/${pr.number}/requested_reviewers`, { + method: "POST", + body: { reviewers: reviewed.reviewers }, + }) + } catch (err) { + console.warn(`::warning::docs-sync: could not assign or request review for ${name}: ${err.message}`) + } + } + + return { + name, + url: pr.html_url, + number: pr.number, + branch, + reviewers: reviewed.reviewers, + draft, + created: !update, + } +} + async function main() { - const { api, appendOutput, appendSummary, repo } = await import("./lib.mjs") + const { api, appendOutput, appendSummary, repo, searchIssues } = await import("./lib.mjs") const now = process.env.PROCESSED_THROUGH ?? new Date().toISOString() const since = process.env.SINCE ?? "unknown" const sinceOverride = process.env.SINCE_OVERRIDE === "true" 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 = now.slice(0, 10) + // The surface map is data, loaded once for the whole run. + const map = loadSurfaceMap() + // The agent's run summary is consumed here and never committed. const agentSummary = readJson(SUMMARY_FILE, []) fs.rmSync(SUMMARY_FILE, { force: true }) @@ -395,81 +647,25 @@ async function main() { // before any commit-creating step, including prepare-branch's merge. git(["add", DOCS_PATH]) git(["commit", "-m", `docs: sync with merged PRs (${date})`]) + const integrationSha = git(["rev-parse", "HEAD"]) // Watermark: now when fully covered; else earliest uncovered merged_at − 1ms. // Pass SINCE as fallback so missing digest-full cannot strand uncovered PRs. + // Computed once (global); per-surface failures never touch it. const through = computeProcessedThrough({ uncovered, digest, now, fallback: since }) - // The draft cap bounds the cumulative PR diff, not just this run's commit. + // Commit the full diff to the integration branch and push it first, so a + // single surface's later failure cannot lose the run's changes. A conflicted + // run pushes its dated fallback branch; a normal run pushes the durability + // ref (see INTEGRATION_BRANCH). 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 = nonContentFiles(changedFiles) - 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 - + const durableBranch = mode === "conflict" ? BRANCH : INTEGRATION_BRANCH git( mode === "update" - ? ["push", "origin", `HEAD:${BRANCH}`] - : ["push", "--force-with-lease", "origin", `HEAD:${BRANCH}`], + ? ["push", "origin", `HEAD:refs/heads/${durableBranch}`] + : ["push", "--force-with-lease", "origin", `HEAD:refs/heads/${durableBranch}`], ) - const { - changesRows: changesNew, - pendingRows: pendingNew, - skippedRows: skippedNew, - } = routeRows({ - summary: agentSummary, - triage, - uncovered, - }) - - let oldChanges = [] - let oldSkipped = [] - let oldPending = [] - let prBody = "" - if (mode === "update" && existingPr) { - const pr = await api(`/repos/${repo()}/pulls/${existingPr}`) - prBody = pr.body ?? "" - oldChanges = extractSectionRows(pr.body, "changes") - oldSkipped = dropLegacySkipped(extractSectionRows(pr.body, "skipped")) - oldPending = extractSectionRows(pr.body, "pending") - } - - // Pending is replaced each run (informational only); do not merge legacy - // pending rows — uncovered is recomputed fresh. oldPending is read only so - // extractSectionRows stays exercised; discarded deliberately. - void oldPending - - const learnedThrough = resolveLearnedThrough({ envValue: process.env.LEARNED_THROUGH, prBody }) - - const body = renderBody({ - date, - since, - through, - learnedThrough, - changesRows: mergeRows(oldChanges, changesNew), - pendingRows: pendingNew, - 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", @@ -479,65 +675,62 @@ async function main() { 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"] } }) - // Best effort: the PR already exists here, so a non-collaborator or a - // revoked account must not fail the run. + const rows = routeRows({ summary: agentSummary, triage, uncovered }) + const groups = groupBySurface(changedFiles, map) + + // Run-log proof: the computed surface for every changed file. + console.log(`docs-sync surface map (${changedFiles.length} changed files):`) + for (const file of changedFiles) console.log(` ${surfaceForDoc(file, map)} <- ${file}`) + + let open = new Map() + try { + open = await openSurfacePrs({ api, searchIssues, repo: repo(), names: surfaceNames(map) }) + } catch (err) { + console.warn(`::warning::docs-sync: could not list open surface PRs; creating fresh branches: ${err.message}`) + } + + const results = [] + for (const name of surfaceNames(map)) { + const files = groups.get(name) + if (!files || files.length === 0) continue try { - await api(`/repos/${repo()}/issues/${prNumber}/assignees`, { - method: "POST", - body: { assignees: [DOCS_OWNER] }, - }) - await api(`/repos/${repo()}/pulls/${prNumber}/requested_reviewers`, { - method: "POST", - body: { reviewers: [DOCS_OWNER] }, + const result = await upsertSurface({ + name, + files, + map, + api, + repo: repo(), + now, + date, + since, + through, + verified, + rows, + openPr: open.get(name) ?? null, + integrationSha, }) + results.push(result) + console.log(`surface ${name}: ${result.created ? "created" : "updated"} ${result.url} reviewers=${result.reviewers.join(",") || "none"}`) } catch (err) { - console.warn(`::warning::docs-sync: could not assign or request review from ${DOCS_OWNER}: ${err.message}`) - } - 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.`, - }, - }) + results.push({ name, error: err }) + console.warn(`::warning::docs-sync: surface ${name} failed: ${err.message}`) } } - appendOutput("pr_url", prUrl) - appendSummary( - `### docs-sync PR\n\n- ${prUrl}\n- changed files: ${changedFiles.length}\n- draft: ${draft}\n- uncovered: ${uncovered.length}\n- processed-through: ${through}\n`, - ) + const summaryLines = ["### docs-sync surface PRs", ""] + for (const r of results) { + summaryLines.push(r.error ? `- **${r.name}**: failed — ${clean(r.error.message)}` : `- **${r.name}**: ${r.url}`) + } + summaryLines.push("", `- changed files: ${changedFiles.length}`) + summaryLines.push(`- uncovered: ${uncovered.length}`) + summaryLines.push(`- processed-through: ${through}`) + appendSummary(summaryLines.join("\n")) + + const prUrl = results.find((r) => r.url)?.url ?? "" + if (prUrl) appendOutput("pr_url", prUrl) + const failures = results.filter((r) => r.error).length console.log( - `PR ${prNumber}: ${prUrl} (draft=${draft}, files=${changedFiles.length}, uncovered=${uncovered.length}, through=${through})`, + `docs-sync: ${results.length - failures} surface PR(s), ${failures} failure(s), files=${changedFiles.length}, uncovered=${uncovered.length}, through=${through}`, ) } diff --git a/.github/workflows/docs-sync.yml b/.github/workflows/docs-sync.yml index 1ae78f3edb45..e7e38f742aa4 100644 --- a/.github/workflows/docs-sync.yml +++ b/.github/workflows/docs-sync.yml @@ -41,8 +41,15 @@ concurrency: 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' }} + TRIAGE_MODEL: ${{ vars.DOCS_SYNC_TRIAGE_MODEL || 'kilo/deepseek/deepseek-v4.1-flash' }} + EDIT_MODEL: ${{ vars.DOCS_SYNC_EDIT_MODEL || 'kilo/deepseek/deepseek-v4.1-flash' }} + # Model id and reasoning effort are both taken from the Kilo gateway model + # list at https://api.kilo.ai/api/openrouter/models (equivalently + # `kilo models kilo`), which serves `deepseek/deepseek-v4.1-flash` with + # `opencode.variants` none/low/high/max; `kilo run --help` documents the + # `--variant` flag that selects one. Overridable, but defaults to `max`: the + # whole point of this bot is hard docs reasoning. + DOCS_SYNC_VARIANT: "max" jobs: selftest: @@ -63,6 +70,9 @@ jobs: - name: Run docs-sync selftest run: node .github/docs-sync/selftest.mjs + - name: Run docs-sync unit tests + run: for f in .github/docs-sync/*.test.mjs; do node "$f"; done + sync: if: github.repository == 'Kilo-Org/kilocode' && github.event_name != 'pull_request' runs-on: blacksmith-4vcpu-ubuntu-2404 @@ -200,7 +210,7 @@ jobs: # user config granting bash, so without --auto the agent cannot run ordinary # shell commands against the repository. kilo run --auto "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 \ + -m "$EDIT_MODEL" --variant "$DOCS_SYNC_VARIANT" --dir "$GITHUB_WORKSPACE" -f docs-sync-out/verify.log \ | node .github/docs-sync/redact-stream.mjs \ | tee -a docs-sync-out/edit-log.txt \ || echo "::warning::kilo fix pass exited nonzero; re-verifying anyway" diff --git a/bun.lock b/bun.lock index 39cfd155e32d..8bedfd1ae081 100644 --- a/bun.lock +++ b/bun.lock @@ -1003,26 +1003,26 @@ }, }, "trustedDependencies": [ - "web-tree-sitter", "esbuild", - "tree-sitter-bash", "protobufjs", + "web-tree-sitter", + "tree-sitter-bash", ], "patchedDependencies": { - "virtua@0.49.1": "patches/virtua@0.49.1.patch", - "mammoth@1.12.0": "patches/mammoth@1.12.0.patch", - "@ff-labs/fff-bun@0.9.4": "patches/@ff-labs%2Ffff-bun@0.9.4.patch", - "@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch", + "@ai-sdk/xai@3.0.102": "patches/@ai-sdk%2Fxai@3.0.102.patch", "@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch", - "@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch", + "bun-pty@0.4.8": "patches/bun-pty@0.4.8.patch", + "@ff-labs/fff-bun@0.9.4": "patches/@ff-labs%2Ffff-bun@0.9.4.patch", "pacote@21.5.1": "patches/pacote@21.5.1.patch", - "@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch", + "@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch", "@ai-sdk/mistral@3.0.51": "patches/@ai-sdk%2Fmistral@3.0.51.patch", "effect@4.0.0-beta.83": "patches/effect@4.0.0-beta.83.patch", + "@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch", "solid-js@1.9.12": "patches/solid-js@1.9.12.patch", - "bun-pty@0.4.8": "patches/bun-pty@0.4.8.patch", "@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch", - "@ai-sdk/xai@3.0.102": "patches/@ai-sdk%2Fxai@3.0.102.patch", + "virtua@0.49.1": "patches/virtua@0.49.1.patch", + "@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch", + "mammoth@1.12.0": "patches/mammoth@1.12.0.patch", }, "overrides": { "@effect/platform-node-shared": "4.0.0-beta.74", diff --git a/patches/ghostty-web@0.3.0.patch b/patches/ghostty-web@0.3.0.patch deleted file mode 100644 index d63a693b88bb..000000000000 --- a/patches/ghostty-web@0.3.0.patch +++ /dev/null @@ -1,40 +0,0 @@ -diff --git a/dist/ghostty-web.js b/dist/ghostty-web.js -index 7c9d64a617bbeb29d757a1acd54686e582868313..2d61098cdb77fa66cbb162897c5590f35cfcf791 100644 ---- a/dist/ghostty-web.js -+++ b/dist/ghostty-web.js -@@ -1285,7 +1285,7 @@ const e = class H { - continue; - } - const C = g.getCodepoint(); -- C === 0 || C < 32 ? B.push(" ") : B.push(String.fromCodePoint(C)); -+ C === 0 || C < 32 || C > 1114111 || (C >= 55296 && C <= 57343) ? B.push(" ") : B.push(String.fromCodePoint(C)); - } - return B.join(""); - } -@@ -1484,7 +1484,7 @@ class _ { - return; - let J = ""; - A.flags & U.ITALIC && (J += "italic "), A.flags & U.BOLD && (J += "bold "), this.ctx.font = `${J}${this.fontSize}px ${this.fontFamily}`, this.ctx.fillStyle = this.rgbToCSS(w, o, i), A.flags & U.FAINT && (this.ctx.globalAlpha = 0.5); -- const s = g, F = C + this.metrics.baseline, a = String.fromCodePoint(A.codepoint || 32); -+ const s = g, F = C + this.metrics.baseline, a = (A.codepoint === 0 || A.codepoint == null || A.codepoint < 0 || A.codepoint > 1114111 || (A.codepoint >= 55296 && A.codepoint <= 57343)) ? " " : String.fromCodePoint(A.codepoint); - if (this.ctx.fillText(a, s, F), A.flags & U.FAINT && (this.ctx.globalAlpha = 1), A.flags & U.UNDERLINE) { - const N = C + this.metrics.baseline + 2; - this.ctx.strokeStyle = this.ctx.fillStyle, this.ctx.lineWidth = 1, this.ctx.beginPath(), this.ctx.moveTo(g, N), this.ctx.lineTo(g + I, N), this.ctx.stroke(); -@@ -1730,7 +1730,7 @@ const L = class R { - let G = ""; - for (let J = M; J <= k; J++) { - const s = o[J]; -- if (s && s.codepoint !== 0) { -+ if (s && s.codepoint !== 0 && s.codepoint <= 1114111 && !(s.codepoint >= 55296 && s.codepoint <= 57343)) { - const F = String.fromCodePoint(s.codepoint); - G += F, F.trim() && (i = G.length); - } else -@@ -1995,7 +1995,7 @@ const L = class R { - if (!Q) - return null; - const g = (w) => { -- if (!w || w.codepoint === 0) -+ if (!w || w.codepoint === 0 || w.codepoint > 1114111 || (w.codepoint >= 55296 && w.codepoint <= 57343)) - return !1; - const o = String.fromCodePoint(w.codepoint); - return /[\w-]/.test(o);