From 64df05864b7521bdc925f71414dc19b89ab62d1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sun, 13 Sep 2026 14:37:39 +0200 Subject: [PATCH 1/2] feat(docs-sync): run DeepSeek V4.1 Flash at max effort and open per-surface PRs https://github.com/Kilo-Org/kilocode/pull/14085 --- .github/docs-sync/edit.mjs | 7 +- .github/docs-sync/learn.mjs | 503 ++++++++------- .github/docs-sync/lib.mjs | 5 + .github/docs-sync/model-config.test.mjs | 166 +++++ .github/docs-sync/prepare-branch.mjs | 184 +++++- .github/docs-sync/reviewers.mjs | 141 +++++ .github/docs-sync/selftest.mjs | 789 +++++++++++++++++++++++- .github/docs-sync/surfaces.json | 52 ++ .github/docs-sync/surfaces.mjs | 151 +++++ .github/docs-sync/surfaces.test.mjs | 258 ++++++++ .github/docs-sync/triage.mjs | 27 +- .github/docs-sync/upsert-pr.mjs | 498 +++++++++++---- .github/docs-sync/watermark.mjs | 32 +- .github/workflows/docs-sync.yml | 16 +- bun.lock | 20 +- patches/ghostty-web@0.3.0.patch | 40 -- 16 files changed, 2435 insertions(+), 454 deletions(-) create mode 100644 .github/docs-sync/model-config.test.mjs create mode 100644 .github/docs-sync/reviewers.mjs create mode 100644 .github/docs-sync/surfaces.json create mode 100644 .github/docs-sync/surfaces.mjs create mode 100644 .github/docs-sync/surfaces.test.mjs delete mode 100644 patches/ghostty-web@0.3.0.patch 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..3fca2bf513af 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 @@ -350,6 +351,26 @@ function git(args) { .trim() } +/** Split a learned-through `commit=` token into the individual surface tips. */ +function watermarkTips(commit) { + return String(commit ?? "") + .split(",") + .map((s) => s.trim()) + .filter(Boolean) +} + +/** The tip of a surface branch: its fetched origin ref, else the local ref, else null. */ +function branchTip(branch) { + for (const ref of [`origin/${branch}`, branch]) { + try { + return git(["rev-parse", "--verify", ref]) + } catch { + // try the next ref + } + } + return null +} + // --- main --- async function main() { @@ -400,221 +421,283 @@ async function extract() { patchFile = fixturePath + ".patched" } - const { api, repo, searchIssues, appendOutput, appendSummary, backoffMsForAttempt, runKilo, sleepSync } = - await import("./lib.mjs") - - let prData - let prBody = "" - let prNumber = "" - let branch = "" - + const { + api, + repo, + searchIssues, + appendOutput, + appendSummary, + backoffMsForAttempt, + REASONING_VARIANT, + runKilo, + sleepSync, + } = await import("./lib.mjs") + + // Step 1: resolve every open auto-docs surface PR. With one PR per surface, + // maintainer corrections live on any of them, so learn from all of them + // rather than a single rolling PR. + const targets = [] if (fixture) { // Fixture mode: skip all API calls. - prData = fixture.pr - prBody = prData.body ?? "" - prNumber = String(prData.number ?? 1) - branch = prData.head?.ref ?? "docs/auto-sync" + const list = Array.isArray(fixture.prs) ? fixture.prs : fixture.pr ? [fixture.pr] : [] + for (const pr of list) { + targets.push({ + index: targets.length, + prData: pr, + prBody: pr.body ?? "", + prNumber: String(pr.number ?? ""), + branch: pr.head?.ref ?? "docs/auto-sync", + comments: Array.isArray(pr.comments) ? pr.comments : list.length === 1 ? (fixture.comments ?? []) : [], + }) + } } else { - // Step 1: resolve the rolling PR. Use prepare-branch.mjs's selection rule so both - // target the same branch. searchIssues takes prs[0] with no author filter (like - // prepare-branch.mjs:69). But trust the body marker only when authored by - // github-actions[bot] (like watermark.mjs:35). The two rules differ on purpose: - // the branch must match what prepare-branch.mjs will check out, but a body is - // editable so its marker needs the author filter. const r = repo() - const prs = await searchIssues(`repo:${r} is:pr is:open label:auto-docs sort:created-desc`, { maxPages: 1 }) - if (prs.length === 0) { - log("no open rolling pull request — nothing to learn from") - - // Read existing learnings from main for empty-state artifacts. - let existing = [] + const prs = await searchIssues(`repo:${r} is:pr is:open label:auto-docs sort:created-desc`, { maxPages: 2 }) + for (const item of prs) { try { - const existingText = git(["show", `origin/main:${LEARNINGS_FILE}`]) - existing = parseLearnings(existingText) - } catch { - existing = [] + const detail = await api(`/repos/${r}/pulls/${item.number}`) + targets.push({ + index: targets.length, + prData: detail, + prBody: detail.body ?? "", + prNumber: String(detail.number), + branch: detail.head?.ref ?? "docs/auto-sync", + comments: null, + }) + } catch (err) { + warn(`could not read auto-docs PR #${item.number}: ${err.message}`) } - log(`no-PR existing entries from main: ${existing.length}`) - writeEmptyStateArtifacts(existing) - appendOutput("count", String(existing.length)) - appendSummary("### docs-sync learnings\n\nNo open auto-docs pull request; extraction skipped.") - return } - prData = await api(`/repos/${r}/pulls/${prs[0].number}`) - prBody = prData.body ?? "" - prNumber = String(prData.number) - branch = prData.head?.ref ?? "docs/auto-sync" } - // Step 2: read existing entries. + if (targets.length === 0) { + log("no open auto-docs pull request — nothing to learn from") + + // Read existing learnings from main for empty-state artifacts. + let existing = [] + try { + const existingText = git(["show", `origin/main:${LEARNINGS_FILE}`]) + existing = parseLearnings(existingText) + } catch { + existing = [] + } + log(`no-PR existing entries from main: ${existing.length}`) + writeEmptyStateArtifacts(existing) + appendOutput("count", String(existing.length)) + appendSummary("### docs-sync learnings\n\nNo open auto-docs pull request; extraction skipped.") + return + } + log(`open auto-docs PRs: ${targets.map((t) => `#${t.prNumber}`).join(", ")}`) + + // Step 2: read existing entries. Every surface branch carries the integration + // tree's copy of LEARNINGS.md, so the first branch is representative. let existing = [] let existingText = "" if (fixture) { existingText = readFileOrEmpty(LEARNINGS_FILE) - existing = parseLearnings(existingText) } else { try { - existingText = git(["show", `origin/${branch}:${LEARNINGS_FILE}`]) + existingText = git(["show", `origin/${targets[0].branch}:${LEARNINGS_FILE}`]) } catch { // branch copy absent — fall back to main, then empty. - // Required for the first live run: the rolling branch predates the seeded file. + // Required for the first live run: the surface branch predates the seeded file. try { existingText = git(["show", `origin/main:${LEARNINGS_FILE}`]) } catch { existingText = "" } } - existing = parseLearnings(existingText) } + existing = parseLearnings(existingText) log(`existing entries: ${existing.length}`) - // Replace the seed with the rolling-branch copy. Every step below can throw, and + // Replace the seed with the branch copy. Every step below can throw, and // these two files are all triage and edit read. writePromptArtifacts(existing) - // Step 3: parse marker. Trust only when authored by github-actions[bot] (like watermark.mjs:35). - let commitWm = null - let commentWm = null - const trusted = prData.user?.login === "github-actions[bot]" - if (trusted) { - ;({ commit: commitWm, comment: commentWm } = parseLearnedThrough(prBody)) - } else { - log("PR author is not github-actions[bot]; ignoring body marker") - } - log(`watermark: commit=${commitWm ?? "none"} comment=${commentWm ?? "none"}`) - - // Step 4: fetch and tip SHA. - let tipSha - if (fixture) { - tipSha = git(["rev-parse", "HEAD"]) - } else { - git(["fetch", "origin", "main", branch]) - tipSha = git(["rev-parse", `origin/${branch}`]) - } - - // Step 5: candidate commits. - let rangeArgs = [`origin/main..origin/${branch}`] - if (fixture) { - // In fixture mode, work from the local repo state. - try { - git(["rev-parse", "--verify", branch]) - rangeArgs = [`origin/main..${branch}`] - } catch { - rangeArgs = [`origin/main..HEAD`] - } - } - - if (commitWm) { - let wmExists = false - try { - git(["cat-file", "-e", `${commitWm}^{commit}`]) - wmExists = true - } catch { - wmExists = false + // Step 3: parse each PR's marker. Trust only when authored by + // github-actions[bot] (like watermark.mjs:35). A marker holds the tips of every + // surface branch learned so far, comma-separated. + for (const t of targets) { + if (t.prData.user?.login !== "github-actions[bot]") { + log(`PR #${t.prNumber} author is not github-actions[bot]; ignoring body marker`) + t.tips = [] + t.commentWm = null + continue } - if (wmExists) { - rangeArgs.push(`^${commitWm}`) + const { commit, comment } = parseLearnedThrough(t.prBody) + t.tips = watermarkTips(commit) + t.commentWm = comment + } + // The union is safe: a tip that is not an ancestor of a branch removes + // nothing from that branch's range. + const tips = [...new Set(targets.flatMap((t) => t.tips ?? []))] + log(`watermark tips: ${tips.length > 0 ? tips.map((s) => s.slice(0, 7)).join(", ") : "none"}`) + + // Step 4: fetch every surface branch and resolve its tip. + const branches = [...new Set(targets.map((t) => t.branch))] + if (!fixture) { + for (const b of branches) { + try { + git(["fetch", "origin", "main", b]) + } catch (err) { + warn(`could not fetch ${b}: ${err.message}`) + } } - // A missing watermark commit (force-push, rebase) drops the exclusion. - // The duplicate-rule-text rejection in validateDelta blocks the re-added duplicate. } + for (const t of targets) { + t.tip = branchTip(t.branch) + if (!t.tip) warn(`could not resolve a tip for ${t.branch}; skipping PR #${t.prNumber}`) + } + const currentTips = [...new Set(targets.map((t) => t.tip).filter(Boolean))] + const learnedCommit = currentTips.join(",") || null - const logOut = git(["log", "--no-merges", "--format=%H|%ae|%cI|%s", ...rangeArgs]) - const rawCommits = logOut ? logOut.split("\n").filter(Boolean) : [] - + // Step 5: candidate commits from every surface branch. const botEmail = "41898282+github-actions[bot]@users.noreply.github.com" const candidates = [] const candidateSources = [] const deletedInWindow = [] + const byTarget = new Map() + const seenSha = new Set() - for (const line of rawCommits) { - const [sha, email, dateIso] = line.split("|") - // Drop commits authored by the sync job itself (criterion 5). - if (email === botEmail) continue - // Everything reachable from main is already excluded by the range (criterion 6). - - // Get the full file list. - let files = [] - try { - const out = git(["show", "--name-only", "--format=", sha]) - files = out - ? out - .split("\n") - .filter(Boolean) - .filter((f) => f) - : [] - } catch { + for (const t of targets) { + const list = [] + if (!t.tip) { + byTarget.set(t.index, list) continue } + // In fixture mode, work from the local repo state when the origin ref is absent. + let rangeArgs = [`origin/main..origin/${t.branch}`] + if (fixture) { + try { + git(["rev-parse", "--verify", t.branch]) + rangeArgs = [`origin/main..${t.branch}`] + } catch { + rangeArgs = [`origin/main..HEAD`] + } + } + for (const tip of tips) { + try { + git(["cat-file", "-e", `${tip}^{commit}`]) + rangeArgs.push(`^${tip}`) + } catch { + // A missing watermark commit (force-push, rebase) drops the exclusion. + // The duplicate-rule-text rejection in validateDelta blocks a re-added duplicate. + } + } - // Get the docs-scoped diff and message. - let message = "" - let docDiff = "" + let rawCommits = [] try { - message = git(["show", "--format=%B", "--no-patch", sha]).trim() - docDiff = git(["show", "--format=", sha, "--", "packages/kilo-docs"]) - // Cap diff sizes. - if (docDiff.length > 20000) docDiff = docDiff.slice(0, 20000) + "\n[truncated]" - } catch { - // skip on error + const logOut = git(["log", "--no-merges", "--format=%H|%ae|%cI|%s", ...rangeArgs]) + rawCommits = logOut ? logOut.split("\n").filter(Boolean) : [] + } catch (err) { + warn(`could not read commits for ${t.branch}: ${err.message}`) + byTarget.set(t.index, list) + continue } - // Drop commits whose docs-scoped diff is empty. - if (!docDiff.trim()) continue + for (const line of rawCommits) { + const [sha, email, dateIso] = line.split("|") + // Drop commits authored by the sync job itself (criterion 5). + if (email === botEmail) continue + if (seenSha.has(sha)) continue + // Everything reachable from main is already excluded by the range (criterion 6). + seenSha.add(sha) - // Collect deleted rule lines from LEARNINGS.md. - for (const dl of docDiff.split("\n")) { - if (!dl.startsWith("-")) continue - const stripped = dl.slice(1).trim() - const parsed = stripped.match(LINE_RE) - if (parsed) { - deletedInWindow.push(clean(parsed.groups.rule).replaceAll("\n", " ")) + // Get the full file list. + let files = [] + try { + const out = git(["show", "--name-only", "--format=", sha]) + files = out + ? out + .split("\n") + .filter(Boolean) + .filter((f) => f) + : [] + } catch { + continue } - } - // Cap total diff data. - const totalDiff = candidates.reduce((n, c) => n + (c.diff ? c.diff.length : 0), 0) - if (totalDiff > 120000) { - log(`diff cap reached at commit ${sha.slice(0, 7)}; truncating`) - candidates.push({ + // Get the docs-scoped diff and message. + let message = "" + let docDiff = "" + try { + message = git(["show", "--format=%B", "--no-patch", sha]).trim() + docDiff = git(["show", "--format=", sha, "--", "packages/kilo-docs"]) + // Cap diff sizes. + if (docDiff.length > 20000) docDiff = docDiff.slice(0, 20000) + "\n[truncated]" + } catch { + // skip on error + } + + // Drop commits whose docs-scoped diff is empty. + if (!docDiff.trim()) continue + + // Collect deleted rule lines from LEARNINGS.md. + for (const dl of docDiff.split("\n")) { + if (!dl.startsWith("-")) continue + const stripped = dl.slice(1).trim() + const parsed = stripped.match(LINE_RE) + if (parsed) { + deletedInWindow.push(clean(parsed.groups.rule).replaceAll("\n", " ")) + } + } + + // Cap total diff data. + const totalDiff = candidates.reduce((n, c) => n + (c.diff ? c.diff.length : 0), 0) + if (totalDiff > 120000) { + log(`diff cap reached at commit ${sha.slice(0, 7)}; truncating`) + const capped = { + source: `commit:${sha.slice(0, 7)}`, + iso: dateIso, + date: dateIso.slice(0, 10), + message, + files, + diff: "[truncated]", + } + candidates.push(capped) + list.push(capped) + candidateSources.push(`commit:${sha.slice(0, 7)}`) + break + } + + const cand = { source: `commit:${sha.slice(0, 7)}`, iso: dateIso, date: dateIso.slice(0, 10), message, files, - diff: "[truncated]", - }) + diff: docDiff, + } + candidates.push(cand) + list.push(cand) candidateSources.push(`commit:${sha.slice(0, 7)}`) - break } - - candidates.push({ - source: `commit:${sha.slice(0, 7)}`, - iso: dateIso, - date: dateIso.slice(0, 10), - message, - files, - diff: docDiff, - }) - candidateSources.push(`commit:${sha.slice(0, 7)}`) + byTarget.set(t.index, list) } - // Step 6: candidate comments. + // Step 6: candidate comments from every surface PR. let allComments = [] - let maxCommentAt = "none" - - if (fixture && fixture.comments) { - allComments = fixture.comments - } else if (prNumber) { - const pages = [] - for (let page = 1; page <= 5; page++) { - const batch = await api(`/repos/${repo()}/pulls/${prNumber}/comments?per_page=100&page=${page}`) - pages.push(...batch) - if (batch.length < 100) break + if (fixture) { + for (const t of targets) { + t.rawComments = t.comments ?? [] + allComments.push(...t.rawComments) + } + } else { + for (const t of targets) { + if (!t.prNumber) continue + const pages = [] + for (let page = 1; page <= 5; page++) { + const batch = await api(`/repos/${repo()}/pulls/${t.prNumber}/comments?per_page=100&page=${page}`) + pages.push(...batch) + if (batch.length < 100) break + } + t.rawComments = pages + allComments.push(...pages) } - allComments = pages } + let maxCommentAt = "none" if (allComments.length > 0) { let max = "" for (const c of allComments) { @@ -623,44 +706,44 @@ async function extract() { maxCommentAt = max || "none" } - // Filter trusted comments. - const trustedComments = allComments.filter((c) => { - if (!isTrustedComment(c)) return false - if (commentWm && c.created_at <= commentWm) return false - return true - }) - - // Step 7: correlate comments to commits. + // Step 7: correlate each PR's trusted comments to that PR's own commits. // A comment is a commit's trigger when c.path is in that commit's full file list // and c.created_at < commit date. The earliest such commit claims it. // Compare parsed timestamps so different timezone offsets do not skew the ordering. - for (const c of trustedComments) { - let best = null - const cTime = Date.parse(c.created_at) - for (const cc of candidates) { - if (!Array.isArray(cc.files) || !cc.files.includes(c.path)) continue - const ccTime = Date.parse(cc.iso) - if (cTime < ccTime) { - if (!best || ccTime < Date.parse(best.iso)) { - best = cc + for (const t of targets) { + const trustedComments = (t.rawComments ?? []).filter((c) => { + if (!isTrustedComment(c)) return false + if (t.commentWm && c.created_at <= t.commentWm) return false + return true + }) + for (const c of trustedComments) { + let best = null + const cTime = Date.parse(c.created_at) + for (const cc of byTarget.get(t.index) ?? []) { + if (!Array.isArray(cc.files) || !cc.files.includes(c.path)) continue + const ccTime = Date.parse(cc.iso) + if (cTime < ccTime) { + if (!best || ccTime < Date.parse(best.iso)) { + best = cc + } } } - } - if (best) { - best.comment = { - author_association: c.author_association, - path: c.path, - body: capBody(c.body), + if (best) { + best.comment = { + author_association: c.author_association, + path: c.path, + body: capBody(c.body), + } + } else { + candidates.push({ + source: `comment:${c.id}`, + date: (c.created_at ?? "").slice(0, 10), + path: c.path, + body: capBody(c.body), + author_association: c.author_association, + }) + candidateSources.push(`comment:${c.id}`) } - } else { - candidates.push({ - source: `comment:${c.id}`, - date: (c.created_at ?? "").slice(0, 10), - path: c.path, - body: capBody(c.body), - author_association: c.author_association, - }) - candidateSources.push(`comment:${c.id}`) } } @@ -675,8 +758,8 @@ async function extract() { `### docs-sync learnings\n\nNo new candidate corrections. Entries: ${existing.length}. Marker route: empty (no candidates).`, ) - const marker = renderLearnedThrough({ commit: tipSha, comment: maxCommentAt }) - await patchOrLogMarker({ prBody, prNumber, marker, fixture, patchFile }) + const marker = renderLearnedThrough({ commit: learnedCommit, comment: maxCommentAt }) + await patchOrLogMarker({ targets, marker, fixture, patchFile }) return } @@ -712,7 +795,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", @@ -766,7 +849,7 @@ async function extract() { // Non-empty validated delta. const newEntries = applyDelta(existing, { add: validated.add, remove: validated.remove }) fs.writeFileSync(`${OUT_DIR}/learnings.json`, JSON.stringify(newEntries, null, 2)) - const marker = renderLearnedThrough({ commit: tipSha, comment: maxCommentAt }) + const marker = renderLearnedThrough({ commit: learnedCommit, comment: maxCommentAt }) const suppressed = process.env.DRY_RUN === "true" || process.env.LEARNINGS_NO_PATCH === "1" if (!suppressed) appendOutput("learned_through", marker) if (suppressed) log(`learned-through output suppressed: ${marker}`) @@ -790,8 +873,8 @@ async function extract() { writePromptArtifacts(existing) appendOutput("count", String(existing.length)) - const marker = renderLearnedThrough({ commit: tipSha, comment: maxCommentAt }) - await patchOrLogMarker({ prBody, prNumber, marker, fixture, patchFile }) + const marker = renderLearnedThrough({ commit: learnedCommit, comment: maxCommentAt }) + await patchOrLogMarker({ targets, marker, fixture, patchFile }) const rejected = validated.rejected.length appendSummary( @@ -827,7 +910,7 @@ function readFileOrEmpty(file) { } } -async function patchOrLogMarker({ prBody, prNumber, marker, fixture, patchFile }) { +async function patchOrLogMarker({ targets, marker, fixture, patchFile }) { const suppressed = process.env.DRY_RUN === "true" || process.env.LEARNINGS_NO_PATCH === "1" if (suppressed) { @@ -846,23 +929,25 @@ async function patchOrLogMarker({ prBody, prNumber, marker, fixture, patchFile } } // Live PATCH: body-only, one line changed. The job already holds pull-requests: write. - // Re-read the body first. The body in hand was fetched before the extraction call, so + // Re-read each body first. The body in hand was fetched before the extraction call, so // patching that copy would drop any edit made in the minutes since. GitHub has no // conditional update for a pull request body, so a short fetch-to-PATCH race remains. const { api, repo } = await import("./lib.mjs") - let latestBody = prBody - try { - const fresh = await api(`/repos/${repo()}/pulls/${prNumber}`) - latestBody = fresh.body ?? "" - } catch (err) { - warn(`could not re-read PR #${prNumber} before the marker PATCH: ${err.message}. Using the earlier body.`) - } - const newBody = patchMarkerIntoBody(latestBody, marker) - await api(`/repos/${repo()}/pulls/${prNumber}`, { - method: "PATCH", - body: { body: newBody }, - }) - log(`PATCHed learned-through marker on PR #${prNumber}`) + for (const t of targets) { + let latestBody = t.prBody + try { + const fresh = await api(`/repos/${repo()}/pulls/${t.prNumber}`) + latestBody = fresh.body ?? "" + } catch (err) { + warn(`could not re-read PR #${t.prNumber} before the marker PATCH: ${err.message}. Using the earlier body.`) + } + const newBody = patchMarkerIntoBody(latestBody, marker) + await api(`/repos/${repo()}/pulls/${t.prNumber}`, { + method: "PATCH", + body: { body: newBody }, + }) + log(`PATCHed learned-through marker on PR #${t.prNumber}`) + } } // --- entry point --- 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..c1959e0068fe 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,163 @@ 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. Delete it whether or not + // an open legacy PR was found: a lingering branch (a closed PR, a partial + // cleanup) still blocks every surface push. + try { + const stale = git(["ls-remote", "--heads", "origin", DEFAULT_BRANCH]) + .split("\n") + .some((line) => line.endsWith(`refs/heads/${DEFAULT_BRANCH}`)) + if (stale) { + try { + git(["push", "origin", "--delete", DEFAULT_BRANCH]) + } catch (err) { + console.warn(`::warning::docs-sync: could not delete the legacy branch ${DEFAULT_BRANCH}: ${err.message}`) + } } - git(["checkout", "-B", branch, "origin/main"]) + } catch (err) { + console.warn(`::warning::docs-sync: could not check for the legacy branch ${DEFAULT_BRANCH}: ${err.message}`) + } + // Drop the stale remote-tracking ref too, otherwise fetching or pushing a + // `docs/auto-sync/` branch later in the run fails on the + // directory/file ref conflict. Deleting a ref that is not present is a no-op. + 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}`) } 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/selftest.mjs b/.github/docs-sync/selftest.mjs index 8341b0ef9adc..e1a5533ceaa0 100644 --- a/.github/docs-sync/selftest.mjs +++ b/.github/docs-sync/selftest.mjs @@ -14,8 +14,8 @@ import path from "node:path" import { fileURLToPath } from "node:url" import { sleepSync } from "./lib.mjs" -import { mergeOrFallback, DEFAULT_BRANCH } from "./prepare-branch.mjs" -import { applyCap } from "./watermark.mjs" +import { mergeOrFallback, DEFAULT_BRANCH, isLegacyRollingPr } from "./prepare-branch.mjs" +import { applyCap, pickWatermark } from "./watermark.mjs" import { computeUncovered, computeProcessedThrough, @@ -25,8 +25,10 @@ import { LEARNINGS_FILE, nonContentFiles, resolveLearnedThrough, + patchProcessedThrough, renderBody, extractSectionRows, + surfaceBranch, } from "./upsert-pr.mjs" import { revertTitleKind, @@ -52,6 +54,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 +2960,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 +3433,758 @@ 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() } } } +} + +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, extraEnv = {}) { + 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"), + ...extraEnv, + }, + }) +} + +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"], + }) - // 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") + 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") + } 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() + } +} + +// --------------------------------------------------------------------------- +// Case 17 — learnings read every open surface PR +// --------------------------------------------------------------------------- +function case17_learnAllSurfaces() { + console.log("case 17 — learnings cover every surface PR") + + const humanBotEmail = "240665456+kiloconnect[bot]@users.noreply.github.com" + + const dir = mktemp("docs-sync-learn-multi-") + initRepoWithIdentity(dir) + fs.writeFileSync(path.join(dir, "base.txt"), "base\n") + gitIn(dir, ["add", "base.txt"]) + gitIn(dir, ["commit", "-m", "base"]) + + const addDoc = (rel, text) => { + const p = path.join(dir, rel) + fs.mkdirSync(path.dirname(p), { recursive: true }) + fs.writeFileSync(p, text) + } + + gitIn(dir, ["checkout", "-q", "-b", "docs/auto-sync/cli"]) + addDoc("packages/kilo-docs/pages/cli.md", "# cli\n") + gitIn(dir, ["add", "packages/kilo-docs"]) + gitIn(dir, ["commit", "-m", "cli edit", "--author", `kiloconnect[bot] <${humanBotEmail}>`]) + const cliSha = gitIn(dir, ["rev-parse", "HEAD"]) + + gitIn(dir, ["checkout", "-q", "main"]) + gitIn(dir, ["checkout", "-q", "-b", "docs/auto-sync/vscode"]) + addDoc("packages/kilo-docs/pages/vscode.md", "# vscode\n") + gitIn(dir, ["add", "packages/kilo-docs"]) + gitIn(dir, ["commit", "-m", "vscode edit", "--author", `kiloconnect[bot] <${humanBotEmail}>`]) + const vscodeSha = gitIn(dir, ["rev-parse", "HEAD"]) + + // origin refs, as actions/checkout would leave them. + gitIn(dir, ["update-ref", "refs/remotes/origin/main", "main"]) + gitIn(dir, ["update-ref", "refs/remotes/origin/docs/auto-sync/cli", "docs/auto-sync/cli"]) + gitIn(dir, ["update-ref", "refs/remotes/origin/docs/auto-sync/vscode", "docs/auto-sync/vscode"]) + fs.mkdirSync(path.join(dir, "docs-sync-out"), { recursive: true }) + + const prs = () => [ + { number: 1, head: { ref: "docs/auto-sync/cli" }, body: "", user: { login: "github-actions[bot]" } }, + { number: 2, head: { ref: "docs/auto-sync/vscode" }, body: "", user: { login: "github-actions[bot]" } }, + ] + + // First run: both branches' corrections must reach the model input. + { + const fixturePath = path.join(dir, "fixture.json") + fs.writeFileSync(fixturePath, JSON.stringify({ prs: prs(), comments: [] }, null, 2)) + const callLog = path.join(dir, "kilo-calls.log") + const kiloDir = makeStubKiloDir({ mode: "extraction-delta", callLog }) + fs.writeFileSync(path.join(dir, "docs-sync-out", "extraction-delta.json"), JSON.stringify({ add: [], remove: [] })) + const result = runNodeScript(LEARN_SCRIPT, { + cwd: dir, + kiloDir, + env: { + TRIAGE_MODEL: "test/model", + DOCS_SYNC_FIXTURE: fixturePath, + LEARNINGS_BUDGET_MINUTES: "1", + DOCS_SYNC_BACKOFF_MS: "0", + }, + }) + assert.equal(result.status, 0, `multi-surface extraction must exit 0: ${result.output}`) + const input = JSON.parse(fs.readFileSync(path.join(dir, "docs-sync-out", "learnings-input.json"), "utf8")) + const sources = input.corrections.map((c) => c.source).sort() + assert.deepEqual( + sources, + [`commit:${cliSha.slice(0, 7)}`, `commit:${vscodeSha.slice(0, 7)}`].sort(), + `every surface branch's correction must be a candidate; got ${JSON.stringify(sources)}`, + ) + const patched = fs.readFileSync(`${fixturePath}.patched`, "utf8") + assert.ok( + patched.includes(cliSha) && patched.includes(vscodeSha), + `the marker must list every learned tip; got ${patched}`, + ) + } + + // Second run: the union watermark covers both branches, so nothing is re-learned. + { + const marker = fs.readFileSync(path.join(dir, "fixture.json.patched"), "utf8") + const fixturePath = path.join(dir, "fixture2.json") + fs.writeFileSync( + fixturePath, + JSON.stringify({ prs: prs().map((p) => ({ ...p, body: marker })), comments: [] }, null, 2), + ) + const callLog2 = path.join(dir, "kilo-calls2.log") + const kiloDir2 = makeStubKiloDir({ mode: "extraction-delta", callLog: callLog2 }) + const result = runNodeScript(LEARN_SCRIPT, { + cwd: dir, + kiloDir: kiloDir2, + env: { + TRIAGE_MODEL: "test/model", + DOCS_SYNC_FIXTURE: fixturePath, + LEARNINGS_BUDGET_MINUTES: "1", + DOCS_SYNC_BACKOFF_MS: "0", + }, + }) + assert.equal(result.status, 0, `second multi-surface run must exit 0: ${result.output}`) + const calls = fs.existsSync(callLog2) ? fs.readFileSync(callLog2, "utf8").trim() : "" + assert.equal(calls, "", "a marker covering every tip must suppress the model call") + } + + // A PR whose branch is gone must not block learning from the others. + { + const fixturePath = path.join(dir, "fixture3.json") + fs.writeFileSync( + fixturePath, + JSON.stringify( + { + prs: [ + { number: 3, head: { ref: "docs/auto-sync/gone" }, body: "", user: { login: "github-actions[bot]" } }, + { number: 4, head: { ref: "docs/auto-sync/cli" }, body: "", user: { login: "github-actions[bot]" } }, + ], + comments: [], + }, + null, + 2, + ), + ) + const callLog3 = path.join(dir, "kilo-calls3.log") + const kiloDir3 = makeStubKiloDir({ mode: "extraction-delta", callLog: callLog3 }) + fs.writeFileSync(path.join(dir, "docs-sync-out", "extraction-delta.json"), JSON.stringify({ add: [], remove: [] })) + const result = runNodeScript(LEARN_SCRIPT, { + cwd: dir, + kiloDir: kiloDir3, + env: { + TRIAGE_MODEL: "test/model", + DOCS_SYNC_FIXTURE: fixturePath, + LEARNINGS_BUDGET_MINUTES: "1", + DOCS_SYNC_BACKOFF_MS: "0", + }, + }) + assert.equal(result.status, 0, `an unreadable branch must not fail the run: ${result.output}`) + const input = JSON.parse(fs.readFileSync(path.join(dir, "docs-sync-out", "learnings-input.json"), "utf8")) + assert.ok( + input.corrections.some((c) => c.source === `commit:${cliSha.slice(0, 7)}`), + "the readable branch must still be learned", + ) + } +} + +// --------------------------------------------------------------------------- +// Case 18 — a lingering legacy ref is deleted without an open legacy PR +// --------------------------------------------------------------------------- +function case18_legacyRefDeletedWithoutPr() { + console.log("case 18 — a lingering legacy ref is deleted without an open legacy PR") + const root = mktemp("docs-sync-prep-legacy-") + 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"]) + + 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"]) + + // A legacy docs/auto-sync branch lingers on origin with no open legacy PR. + // It must be pushed from a temp branch: origin (like a local repo) refuses to + // hold both `docs/auto-sync` and `docs/auto-sync/cli`. + gitIn(repoDir, ["checkout", "-q", "-b", "legacy-tmp", "main"]) + write("packages/kilo-docs/pages/getting-started/legacy.md", "# legacy\n") + gitIn(repoDir, ["add", "packages/kilo-docs"]) + gitIn(repoDir, ["commit", "-m", "legacy"]) + const legacySha = gitIn(repoDir, ["rev-parse", "HEAD"]) + gitIn(repoDir, ["push", "-q", "origin", "HEAD:refs/heads/docs/auto-sync"]) + + // actions/checkout leaves the remote-tracking ref for every origin branch. + gitIn(repoDir, ["update-ref", "refs/remotes/origin/docs/auto-sync", legacySha]) + gitIn(repoDir, ["checkout", "-q", "main"]) + gitIn(repoDir, ["branch", "-D", "legacy-tmp"]) + + 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 remote = gitIn(repoDir, ["ls-remote", "--heads", "origin", "docs/auto-sync"]) + assert.equal(remote, "", `the lingering legacy remote branch must be deleted; got:\n${remote}`) + assert.throws( + () => gitIn(repoDir, ["rev-parse", "--verify", "refs/remotes/origin/docs/auto-sync"]), + "the stale remote-tracking ref must be removed", + ) + } finally { + stub.child.kill() + } +} + +// --------------------------------------------------------------------------- +// Case 19 — watermark pick and processed-through refresh helpers +// --------------------------------------------------------------------------- +function case19_watermarkAndMarkerHelpers() { + console.log("case 19 — watermark pick and processed-through refresh") + + const mk = (iso, number) => ({ + number, + user: { login: "github-actions[bot]" }, + body: `x\n\n`, + }) + + assert.equal(pickWatermark([]), null) + assert.equal( + pickWatermark([ + { number: 1, user: { login: "human" }, body: "" }, + ]), + null, + "an untrusted author's marker must be ignored", + ) + + // The search is updated-desc, so the first trusted entry is the latest run's + // marker even when a later entry carries a larger (stale) one. + const picked = pickWatermark([mk("2026-08-01T00:00:00.000Z", 2), mk("2026-09-01T00:00:00.000Z", 1)]) + assert.equal(picked.number, 2) + assert.equal(picked.marker.toISOString(), "2026-08-01T00:00:00.000Z") + + // patchProcessedThrough replaces in place, appends when absent, exactly one marker. + const replaced = patchProcessedThrough( + "body\n\n", + "new", + ) + assert.ok(replaced.includes("")) + assert.ok(!replaced.includes("processed-through 2020")) + assert.equal((replaced.match(/")) + + // Anti-drift: the watermark query orders by updated, and upsert refreshes the + // marker on a skipped surface's open PR. + const wmSrc = fs.readFileSync(path.join(HERE, "watermark.mjs"), "utf8") + assert.ok(wmSrc.includes("sort:updated-desc"), "watermark query must sort by updated-desc") + const upsertSrc = fs.readFileSync(UPSERT_SCRIPT, "utf8") + assert.ok( + /patchProcessedThrough\(openPr\.body/.test(upsertSrc), + "upsert must refresh a skipped surface's processed-through marker", + ) +} + +// --------------------------------------------------------------------------- +// Case 20 — a skipped surface's open PR gets the current marker +// --------------------------------------------------------------------------- +function case20_upsertRefreshesSkippedMarker() { + console.log("case 20 — a skipped surface's open PR gets the current marker") + + const stub = startSurfaceStub({ + openPrs: [ + { + number: 42, + head: `${SURFACE_PREFIX}gateway`, + body: "gateway body\n\n\n", + }, + ], + }) + try { + const { root, repoDir } = setupSurfaceRepo() + // Make cli the only changed surface: the gateway PR has no changed files + // this run and would otherwise keep its stale markers. + for (const name of ["vscode", "gateway", "other"]) { + fs.rmSync(path.join(repoDir, SURFACE_PAGE_FILES[name])) + } + + const learned = "" + const result = runUpsert(repoDir, root, stub.port, { LEARNED_THROUGH: learned }) + assert.equal(result.status, 0, `upsert must exit 0: ${result.output}`) + + const patched = readStubLog(stub.logFile).find((e) => e.method === "PATCH" && e.url.includes("/pulls/42")) + assert.ok(patched, "the skipped gateway PR must be PATCHed with the current marker") + assert.ok( + patched.body.body.includes(``), + `the skipped surface's marker must advance; got ${patched.body.body}`, + ) + assert.ok(!patched.body.body.includes("processed-through 2020"), "the stale marker must be replaced") + assert.ok(patched.body.body.includes(learned), "the learned-through marker must also advance") + assert.ok(!patched.body.body.includes("commit=oldtip"), "the stale learned-through marker must be replaced") + } finally { + stub.child.kill() + } } // --------------------------------------------------------------------------- @@ -3475,6 +4211,15 @@ function main() { case9_reverts, case10_learnings, case11_prOwner, + case12_surfaceSegmentation, + case13_surfaceFailureIsolated, + case14_prepareBranchSurfaces, + case15_surfaceUpdate, + case16_surfaceDeletion, + case17_learnAllSurfaces, + case18_legacyRefDeletedWithoutPr, + case19_watermarkAndMarkerHelpers, + case20_upsertRefreshesSkippedMarker, ] 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..9ce570d74062 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)} @@ -334,6 +358,29 @@ export function resolveLearnedThrough({ envValue, prBody }) { return m ? m[0] : "" } +/** + * Replace the processed-through marker in an existing PR body. Used to refresh + * an open surface PR whose surface produced no changed files this run. Without + * it that PR keeps an older marker; since the watermark is read from the latest + * auto-docs PR, a skipped surface could regress or pin it. + */ +export function patchProcessedThrough(body, through) { + const marker = `` + const re = // + const b = String(body ?? "") + if (re.test(b)) return b.replace(re, marker) + return b + `\n${marker}\n` +} + +/** Replace the learned-through marker in an existing PR body. No-op for an empty marker. */ +export function patchLearnedThrough(body, marker) { + if (!marker) return String(body ?? "") + const re = // + const b = String(body ?? "") + if (re.test(b)) return b.replace(re, marker) + return b + `\n${marker}\n` +} + /** * No-diff early-return report. Returns summary markdown and an optional * replay warning. Warns IFF sinceOverride && uncovered non-empty (no commit @@ -361,17 +408,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 +670,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 +698,82 @@ 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}`) + results.push({ name, error: err }) + console.warn(`::warning::docs-sync: surface ${name} failed: ${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.`, - }, - }) + } + + // A surface with no changed files is skipped above, so its open PR keeps its + // previous processed-through marker. Refresh that marker to this run's value + // so a stale marker on a skipped surface cannot regress or pin the watermark + // (which is read from the latest auto-docs PR). + for (const name of surfaceNames(map)) { + const files = groups.get(name) + if (files && files.length > 0) continue + const openPr = open.get(name) + if (!openPr) continue + try { + const learned = resolveLearnedThrough({ envValue: process.env.LEARNED_THROUGH, prBody: openPr.body ?? "" }) + let body = patchProcessedThrough(openPr.body ?? "", through) + if (learned) body = patchLearnedThrough(body, learned) + await api(`/repos/${repo()}/pulls/${openPr.number}`, { method: "PATCH", body: { body } }) + console.log(`surface ${name}: refreshed markers on #${openPr.number}`) + } catch (err) { + console.warn(`::warning::docs-sync: could not refresh the processed-through marker for ${name}: ${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/docs-sync/watermark.mjs b/.github/docs-sync/watermark.mjs index 32986fef67f5..eba949c59887 100644 --- a/.github/docs-sync/watermark.mjs +++ b/.github/docs-sync/watermark.mjs @@ -24,20 +24,32 @@ function extractMarker(body) { return Number.isNaN(d.getTime()) ? null : d } +/** + * Pick the marker from the first trusted PR in `prs`. The query is sorted by + * `updated-desc`, so that PR is the one the latest run refreshed. Scanning in + * `created-desc` order instead could read a surface PR that this run skipped + * (its body still carries an older marker) and regress or pin the watermark. + */ +export function pickWatermark(prs) { + for (const pr of Array.isArray(prs) ? prs : []) { + // Only trust markers on PRs authored by the bot itself: bodies are + // editable and the label can be applied by anyone with triage access. + if (pr.user?.login !== "github-actions[bot]") continue + const marker = extractMarker(pr.body) + if (marker) return { number: pr.number, marker } + } + return null +} + async function findWatermark() { const r = repo() for (const state of ["open", "merged"]) { - const query = `repo:${r} is:pr label:auto-docs sort:created-desc ${state === "open" ? "is:open" : "is:merged"}` + const query = `repo:${r} is:pr label:auto-docs sort:updated-desc ${state === "open" ? "is:open" : "is:merged"}` const prs = await searchIssues(query, { maxPages: 1 }) - for (const pr of prs) { - // Only trust markers on PRs authored by the bot itself: bodies are - // editable and the label can be applied by anyone with triage access. - if (pr.user?.login !== "github-actions[bot]") continue - const marker = extractMarker(pr.body) - if (marker) { - console.log(`watermark from ${state} PR #${pr.number}: ${marker.toISOString()}`) - return marker - } + const picked = pickWatermark(prs) + if (picked) { + console.log(`watermark from ${state} PR #${picked.number}: ${picked.marker.toISOString()}`) + return picked.marker } } return null 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); From 0d36f81365059ba894959c3d58ab890210cc12da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sun, 13 Sep 2026 14:41:53 +0000 Subject: [PATCH 2/2] feat(docs-sync): run DeepSeek V4.1 Flash at max effort and open per-surface PRs Surface: the docs sync GitHub Action (`.github/docs-sync/**`). Update the body of PR https://github.com/Kilo-Org/kilocode/pull/14085 and nothing else. Change no source file unless a test change is needed to produce the proof. Two gaps in that PR body: 1. The PR deletes `patches/ghostty-web@0.3.0.patch` and reorders the `trustedDependencies` and `patchedDependencies` lists in `bun.lock`, and the body does not say why. The reason: no dependency is patched with ghostty-web, so `bun install` prunes the orphan patch file and rewrites those lists in its own order. Add two lines to the body that state this. Do NOT restore the file and do not revert the lockfile. 2. The body says "No live proof applies" and leaves an owner verification item asking a person to dispatch the workflow. That is wrong twice: the owner must never be handed work the workflow can do, and this action IS provable offline. Run `.github/docs-sync/selftest.mjs` against a fixture change set that spans two product surfaces plus one unmapped file, and capture its real output. Put that output in the body as the proof, showing: the computed surface map, the two assignees chosen per surface, the reviewers requested, and the title of each pull request the run would open. Then remove the owner verification item. Rules: open no real pull request, request review from no real person, and call no live GitHub write API while proving it. The proof must be the selftest's own output, quoted, not a description of it. --- .github/docs-sync/selftest.mjs | 92 ++++++++++++++++++++++++++++++++++ 1 file changed, 92 insertions(+) diff --git a/.github/docs-sync/selftest.mjs b/.github/docs-sync/selftest.mjs index e1a5533ceaa0..c3f81e79828a 100644 --- a/.github/docs-sync/selftest.mjs +++ b/.github/docs-sync/selftest.mjs @@ -4187,6 +4187,97 @@ function case20_upsertRefreshesSkippedMarker() { } } +// --------------------------------------------------------------------------- +// Case 21 — a fixture that spans two product surfaces plus one unmapped file +// --------------------------------------------------------------------------- +// The owner request's acceptance case: two product surfaces plus one unmapped +// file must produce exactly three PRs with the right assignees and reviewers, +// and no changed file may appear in two PRs. This case also prints the proof a +// PR body quotes: the computed surface map, the two assignees chosen per +// surface, the reviewers requested, and the title of each PR the run opens. +function case21_twoSurfacesOneUnmapped() { + console.log("case 21 — two product surfaces plus one unmapped file") + const stub = startSurfaceStub({ + openPrs: [], + commits: { + "packages/opencode/": [stubCommit("alice", 1), stubCommit("bob", 5)], + "packages/kilo-vscode/": [stubCommit("erin", 1), stubCommit("frank", 3)], + }, + permissions: { alice: "write", bob: "write", erin: "write", frank: "write" }, + }) + try { + const { root, repoDir } = setupSurfaceRepo() + // Narrow the fixture to the two product surfaces (cli, vscode) plus the + // unmapped community page: drop the third product surface's file. + fs.rmSync(path.join(repoDir, SURFACE_PAGE_FILES.gateway)) + + 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, + `exactly three 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}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("other"), { + assignees: ["lambertjosh", "intentionally-left-nil"], + reviewers: ["lambertjosh", "intentionally-left-nil"], + }) + assert.match(byHead.get(`${SURFACE_PREFIX}other`).body.body, /fixed reviewers/i) + + // Each surface branch carries exactly its own changed file; no file twice. + const branchFiles = new Map() + for (const name of ["cli", "vscode", "other"]) { + const head = `${SURFACE_PREFIX}${name}` + const diff = gitIn(repoDir, ["diff", "--name-only", "origin/main", `origin/${head}`]) + branchFiles.set(name, diff.split("\n").filter(Boolean)) + } + assert.deepEqual(branchFiles.get("cli"), [SURFACE_PAGE_FILES.cli]) + assert.deepEqual(branchFiles.get("vscode"), [SURFACE_PAGE_FILES.vscode]) + assert.deepEqual(branchFiles.get("other"), [SURFACE_PAGE_FILES.other]) + const flat = [...branchFiles.values()].flat() + assert.equal(flat.length, 3, "three changed files total") + assert.equal(new Set(flat).size, 3, "no changed file may appear in two PRs") + + // The proof a PR body quotes, from this run. + console.log(" computed surface map (changed file -> surface):") + for (const [name, files] of branchFiles) { + for (const file of files) console.log(` ${file} -> ${name}`) + } + for (const name of ["cli", "vscode", "other"]) { + const create = byHead.get(`${SURFACE_PREFIX}${name}`) + const pair = pairFor(name) + console.log(` pull request: ${create.head}`) + console.log(` title: ${create.body.title}`) + console.log(` assignees: ${pair.assignees.join(", ")}`) + console.log(` requested reviewers: ${pair.reviewers.join(", ")}`) + } + } finally { + stub.child.kill() + } +} + // --------------------------------------------------------------------------- // main // --------------------------------------------------------------------------- @@ -4220,6 +4311,7 @@ function main() { case18_legacyRefDeletedWithoutPr, case19_watermarkAndMarkerHelpers, case20_upsertRefreshesSkippedMarker, + case21_twoSurfacesOneUnmapped, ] let failed = 0 for (const fn of cases) {