diff --git a/.github/docs-sync/collect.mjs b/.github/docs-sync/collect.mjs index 51be1a3d8e1..75ab641039c 100644 --- a/.github/docs-sync/collect.mjs +++ b/.github/docs-sync/collect.mjs @@ -7,15 +7,20 @@ * * Pre-filter drops (triage never sees these): * - PRs labeled auto-docs (this bot's own rolling PRs) - * - chore/test/ci/build/docs/style/refactor/revert conventional titles + * - chore/test/ci/build/docs/style/refactor conventional titles * - PRs touching only docs/non-product paths * + * Revert PRs (conventional `revert(...):` and GitHub-native `Revert "..."` titles) + * are intercepted as signals, never added to the digest; affected digest entries + * gain `reverted_by` so triage/edit can skip them. + * * Bot-authored PRs are kept: release/dependency bots ship user-facing * changes too, and the label + docs-only guards above prevent loops. */ import fs from "node:fs" import { api, appendOutput, appendSummary, listPrFiles, searchIssues } from "./lib.mjs" +import { revertTitleKind, parseRevertTargets, applyRevertAnnotations, unannotatedRevertSignals } from "./reverts.mjs" const SOURCE_REPOS = ["Kilo-Org/cloud", "Kilo-Org/kilocode"] const OUT_DIR = "docs-sync-out" @@ -23,6 +28,7 @@ const BODY_LIMIT = 2000 const SLIM_BODY_LIMIT = 300 const PATCH_LIMIT = 8000 const FILE_LIMIT = 30 +// Revert PRs (conventional AND GitHub-native) are intercepted below BEFORE this filter; the "revert" alternative here is unreachable and kept only to minimize diff. const DROP_TITLE = /^(chore|test|ci|build|docs|style|refactor|revert)(\(.+\))?!?:/i const DOCS_ONLY_PATH = /^(packages\/kilo-docs\/|\.github\/docs-sync\/|docs-sync-out\/|docs\/|[^/]+\.md$)/ @@ -44,7 +50,8 @@ const since = argSince() console.log(`collecting PRs merged since ${since.toISOString()}`) const digest = [] -const dropped = { label: 0, title: 0, docs_only: 0, fetch_error: 0 } +const dropped = { label: 0, title: 0, docs_only: 0, fetch_error: 0, revert: 0 } +const reverts = [] for (const fullRepo of SOURCE_REPOS) { const prs = await mergedPrs(fullRepo, since) @@ -56,6 +63,22 @@ for (const fullRepo of SOURCE_REPOS) { dropped.label++ continue } + if (revertTitleKind(item.title ?? "")) { + try { + const pr = await api(`/repos/${fullRepo}/pulls/${item.number}`) + const targets = parseRevertTargets(pr.body ?? "", fullRepo) + reverts.push({ url: pr.html_url, merged_at: pr.merged_at, targets }) + if (targets.length === 0) { + console.warn(`::warning::revert PR ${fullRepo}#${item.number} has no parseable targets`) + } + dropped.revert++ + } catch (err) { + // One dead revert PR must not abort the run; its targets just go unannotated. + console.warn(`::warning::skipping revert ${fullRepo}#${item.number}: ${err.message}`) + dropped.fetch_error++ + } + continue + } if (DROP_TITLE.test(item.title ?? "")) { dropped.title++ continue @@ -108,6 +131,9 @@ for (const fullRepo of SOURCE_REPOS) { } } +const applied = applyRevertAnnotations(digest, reverts) +const unannotated = unannotatedRevertSignals(reverts, applied) + digest.sort((a, b) => new Date(a.merged_at) - new Date(b.merged_at)) fs.mkdirSync(OUT_DIR, { recursive: true }) @@ -124,14 +150,31 @@ console.log(`kept ${digest.length} PRs, dropped:`, dropped) appendOutput("count", digest.length) appendOutput("digest", `${OUT_DIR}/digest.json`) -appendSummary( - [ - "### docs-sync collect", - "", - `- window: since \`${since.toISOString()}\``, - `- kept: **${digest.length}** PRs`, - `- dropped: ${dropped.label} auto-docs, ${dropped.title} title filter, ${dropped.docs_only} docs-only, ${dropped.fetch_error} fetch errors`, - "", - ...digest.map((d) => `- [${d.repo}#${d.number}](${d.url}) ${d.title}`), - ].join("\n"), -) +const summaryLines = [ + "### docs-sync collect", + "", + `- window: since \`${since.toISOString()}\``, + `- kept: **${digest.length}** PRs`, + `- dropped: ${dropped.label} auto-docs, ${dropped.title} title filter, ${dropped.docs_only} docs-only, ${dropped.fetch_error} fetch errors, ${dropped.revert} reverts intercepted`, + "", + ...digest.map((d) => `- [${d.repo}#${d.number}](${d.url}) ${d.title}`), +] +if (applied.length > 0) { + summaryLines.push("", "**revert annotations:**", ...applied.map(([target, reverter]) => `- ${target} — reverted by ${reverter}`)) +} +if (unannotated.missed.length > 0 || unannotated.unparsed.length > 0) { + summaryLines.push("", "**revert targets with no in-window annotation:**") + for (const m of unannotated.missed) { + summaryLines.push(`- ${m.url} — unannotated targets: ${m.targets.join(", ")}`) + } + for (const url of unannotated.unparsed) { + summaryLines.push(`- ${url} (no parseable targets)`) + } +} +if (unannotated.chains.length > 0) { + summaryLines.push("", "**revert chains (not annotated):**") + for (const c of unannotated.chains) { + summaryLines.push(`- ${c.url}${c.targets.length > 0 ? ` (targets: ${c.targets.join(", ")})` : ""}`) + } +} +appendSummary(summaryLines.join("\n")) diff --git a/.github/docs-sync/edit-prompt.md b/.github/docs-sync/edit-prompt.md index f0099e588f7..c9a2b86abed 100644 --- a/.github/docs-sync/edit-prompt.md +++ b/.github/docs-sync/edit-prompt.md @@ -4,13 +4,16 @@ Before writing anything: 1. Read `packages/kilo-docs/AGENTS.md` and `packages/kilo-docs/STYLE_GUIDE.md` and follow them exactly: Markdoc custom tags, the `/docs` prefix in image paths, navigation files under `lib/nav/`, redirect rules, and the generated-screenshot policy. 2. Read the attached batch files: the full-details file (PR title, body, file list, `patch_excerpt` diffs) and the triage file (docs-worthiness verdicts, target sections, priorities). +3. Verify facts against the current source tree — for Kilo-Org/kilocode PRs. This checkout reflects current kilocode main: before documenting a command, flag, setting, default, or behavior from a kilocode PR, confirm it exists in the current source. Existence alone is not enough: defaults, whether an option is required or optional, and on-by-default behavior must also match the current tree — a symbol that still exists as opt-in does not justify documenting default-on behavior. When the merged diff and the current tree disagree, the current tree wins — the change may have been reverted or superseded; skip it and record why. For Kilo-Org/cloud PRs the source is not in this checkout: rely on the PR diff and body, and on the `reverted_by` field below. Reading any file in the checkout for verification is expected; the hard rule against touching anything outside `packages/kilo-docs/` applies to writing only. -For each PR in the batch, in priority order: +For each PR in the batch, in priority order, first decide whether it needs documentation at all — skipping is a first-class outcome. For each one that does: - Find the most relevant existing docs page(s) and make minimal, precise updates in the style of the surrounding content. - Create a new page only when no existing page fits; then add it to the matching nav file in `packages/kilo-docs/lib/nav/`. -- Document only behavior that is actually present in the merged diff. If the PR body or diff shows the feature is behind a flag or otherwise not user-visible yet, skip it and record why. +- Document only behavior that is actually present in the merged diff and still present in the product now (step 3 above: when they disagree, the current state wins). If the PR body or diff shows the feature is behind a flag or otherwise not user-visible yet, skip it and record why. - If a PR turns out not to need documentation, skip it and record why. Trust evidence over the triage verdict. +- A batch PR with a `reverted_by` field was reverted by that PR; skip it unless there is clear evidence the change is present now — for kilocode PRs verify it in the current source tree (re-land); for cloud PRs require explicit re-land evidence in the entry itself. +- Skipping is a normal outcome: a batch where every PR is skipped is a valid result. Never write docs just to have something to show. Hard rules: diff --git a/.github/docs-sync/reverts.mjs b/.github/docs-sync/reverts.mjs new file mode 100644 index 00000000000..5702fa37437 --- /dev/null +++ b/.github/docs-sync/reverts.mjs @@ -0,0 +1,153 @@ +// kilocode_change - new file + +/** + * Pure helpers for intercepting revert PRs during docs-sync collect. + * No I/O, no imports — offline-testable title/body/annotation logic only. + */ + +/** Detect revert PR titles. Returns "conventional" | "github-native" | null. */ +export function revertTitleKind(title) { + const t = String(title ?? "") + if (/^revert(\(.+\))?!?:/i.test(t)) return "conventional" + if (/^revert\s+["']/i.test(t)) return "github-native" + return null +} + +/** + * Parse revert targets from a PR body. `defaultRepo` ("Kilo-Org/kilocode") resolves bare `#N`. + * Returns [{ repo, number, url }] with url = `https://github.com/${repo}/pull/${number}`. + * Handles the conventional trailer form: a line starting with `Reverts` (case-insensitive), + * e.g. `Reverts #12249 and #12481.`, `Reverts Kilo-Org/cloud#42.`, comma-separated lists, + * several such lines in one body, and bulleted/quoted single-line trailers + * (`- Reverts #12249`, `> Reverts #12249`). + * NOT handled (documented limitation): `This reverts commit .` (no PR number), + * mid-sentence forms (`This reverts #5.`), narrative mentions (`Revert the fix in #99999` + * is prose, not a trailer — must NOT produce a target), `Revertsomething #5` (prose glued + * to the word — `\b` word boundary makes it inert), and multi-line lists + * (`Reverts:\n- #1\n- #2`). + */ +export function parseRevertTargets(body, defaultRepo) { + const text = String(body ?? "") + const repoDefault = String(defaultRepo ?? "") + const seen = new Set() + const out = [] + const lineRe = /^[ \t>*-]*reverts\b[ \t:]*([^\n]*)/gim + let lineMatch + while ((lineMatch = lineRe.exec(text)) !== null) { + const capture = lineMatch[1] ?? "" + const refRe = /(?:([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+))?#(\d+)/g + let refMatch + while ((refMatch = refRe.exec(capture)) !== null) { + const repo = refMatch[1] || repoDefault + const number = Number(refMatch[2]) + if (!repo || !Number.isInteger(number)) continue + const url = `https://github.com/${repo}/pull/${number}` + if (seen.has(url)) continue + seen.add(url) + out.push({ repo, number, url }) + } + } + return out +} + +/** + * revertSignals: [{ url, merged_at, targets: [{ repo, number, url }] }] + * Returns Map — the annotation for each reverted PR. + * Map keys are lowercased target urls; values keep signal.url as authored. + * Revert-of-revert: a signal whose own url is itself a target of another signal is + * dropped entirely (a re-land: net effect zero — one set lookup, no chain walking). + */ +export function computeRevertAnnotations(revertSignals) { + const signals = Array.isArray(revertSignals) ? revertSignals : [] + const revertedUrls = new Set() + for (const signal of signals) { + for (const t of signal?.targets ?? []) { + if (t?.url) revertedUrls.add(t.url.toLowerCase()) + } + } + const annotations = new Map() + for (const signal of signals) { + if (!signal?.url || revertedUrls.has(signal.url.toLowerCase())) continue + for (const t of signal.targets ?? []) { + if (!t?.url) continue + annotations.set(t.url.toLowerCase(), { url: signal.url, merged_at: signal.merged_at }) + } + } + return annotations +} + +/** + * Applies computeRevertAnnotations to digest entries in place: an entry whose url + * was reverted gains `reverted_by: { url, merged_at }` (the reverter). + * Lookup is case-insensitive; applied pairs still report entry.url (canonical). + * Returns applied [targetUrl, reverterUrl] pairs (digest entries only) for reporting. + */ +export function applyRevertAnnotations(digest, revertSignals) { + const annotations = computeRevertAnnotations(revertSignals) + const applied = [] + for (const entry of digest ?? []) { + if (!entry?.url) continue + const ann = annotations.get(entry.url.toLowerCase()) + if (!ann) continue + entry.reverted_by = { url: ann.url, merged_at: ann.merged_at } + applied.push([entry.url, ann.url]) + } + return applied +} + +/** + * Reports intercepted reverts whose targets received no digest annotation, for the + * step summary. `signals` is the same shape as computeRevertAnnotations takes; + * `appliedPairs` is applyRevertAnnotations' return ([targetUrl, reverterUrl]). + * Returns { missed, unparsed, chains }: + * - missed: [{ url, targets: [targetUrl, ...] }] — one entry per signal with at least + * one unannotated plain target, listing exactly the targets that missed (a partially-covered + * revert still surfaces its uncovered targets). Plain = not itself an intercepted revert. + * - unparsed: [url, ...] — urls of signals with zero targets (they already warn at + * intercept time). + * - chains: [{ url, targets: [targetUrl, ...] }] — signals involved in a revert chain, + * reported for human visibility only; the pipeline never resolves a chain's net effect. + * A signal lands here when EITHER its own url is a target of another signal (cancelled + * by computeRevertAnnotations — lists ALL its targets), OR at least one of its targets + * is itself an intercepted revert (re-land chain link — lists ONLY those chain-link + * targets; plain targets still flow through missed). + * Cancelled signals skip missed/unparsed. Chain-linked (non-cancelled) signals may also + * appear in missed for their unannotated plain targets. Chain-link targets never appear + * in missed. All url comparisons are case-insensitive. + */ +export function unannotatedRevertSignals(signals, appliedPairs) { + const list = Array.isArray(signals) ? signals : [] + const annotated = new Set((appliedPairs ?? []).map(([target]) => String(target ?? "").toLowerCase())) + const signalUrls = new Set(list.map((s) => String(s?.url ?? "").toLowerCase())) + const cancelledUrls = new Set() + for (const s of list) { + for (const t of s?.targets ?? []) { + if (t?.url) cancelledUrls.add(t.url.toLowerCase()) + } + } + const missed = [] + const unparsed = [] + const chains = [] + for (const s of list) { + const url = s?.url + if (!url) continue + const targets = (s.targets ?? []).map((t) => t?.url).filter(Boolean) + const cancelled = cancelledUrls.has(url.toLowerCase()) + const chainTargets = targets.filter((u) => signalUrls.has(u.toLowerCase())) + if (cancelled) { + chains.push({ url, targets }) + continue + } + if (chainTargets.length > 0) { + chains.push({ url, targets: chainTargets }) + } + if (targets.length === 0) { + unparsed.push(url) + continue + } + const plainTargets = targets.filter((u) => !signalUrls.has(u.toLowerCase())) + const missing = plainTargets.filter((u) => !annotated.has(u.toLowerCase())) + if (missing.length > 0) missed.push({ url, targets: missing }) + } + return { missed, unparsed, chains } +} diff --git a/.github/docs-sync/selftest.mjs b/.github/docs-sync/selftest.mjs index b720ba67cb5..9af35c58cdf 100644 --- a/.github/docs-sync/selftest.mjs +++ b/.github/docs-sync/selftest.mjs @@ -24,6 +24,13 @@ import { renderBody, extractSectionRows, } from "./upsert-pr.mjs" +import { + revertTitleKind, + parseRevertTargets, + computeRevertAnnotations, + applyRevertAnnotations, + unannotatedRevertSignals, +} from "./reverts.mjs" const HERE = path.dirname(fileURLToPath(import.meta.url)) const EDIT_SCRIPT = path.join(HERE, "edit.mjs") @@ -1403,6 +1410,420 @@ function case8_triage() { } } +// --------------------------------------------------------------------------- +// Case 9 — revert interception (title/body/annotations + source-order guards) +// --------------------------------------------------------------------------- +function case9_reverts() { + console.log("case 9: revert interception") + + // --- revertTitleKind --- + assert.equal(revertTitleKind('revert(cli): restore opt-in stream idle timeouts'), "conventional") + assert.equal(revertTitleKind('Revert "feat(cli): default stream watchdog"'), "github-native") + assert.equal(revertTitleKind("REVERT: all of it"), "conventional") + assert.equal(revertTitleKind("feat(cli): add x"), null) + assert.equal(revertTitleKind("docs: update y"), null) + assert.equal(revertTitleKind("Reverted behavior docs"), null) + + // --- parseRevertTargets --- + const defaultRepo = "Kilo-Org/kilocode" + const body12497 = `The default stream inactivity watchdog introduced by #12249 aborts requests based only on the absence of normalized AI SDK events. That signal cannot distinguish a dead provider stream from long prompt processing, reasoning, buffering, or transport behavior, and the follow-up in #12481 reduces false positives without resolving that ambiguity. + +Revert both changes and restore the previous opt-in contract: Kilo does not impose a stream idle timeout unless the provider configuration explicitly sets \`chunkTimeout\`. Explicit provider timeouts continue to use the existing AI SDK and SSE timeout paths. This removes the global heuristic while the underlying stalled-stream source and the required transport-level observability are investigated. + +This deliberately restores the possibility that an unconfigured provider stream can remain open indefinitely. A default watchdog should be reintroduced only with evidence that its liveness signal and threshold do not terminate healthy responses. + +Reverts #12249 and #12481. +` + { + const targets = parseRevertTargets(body12497, defaultRepo) + assert.equal(targets.length, 2) + assert.deepEqual( + targets.map((t) => ({ repo: t.repo, number: t.number, url: t.url })), + [ + { + repo: "Kilo-Org/kilocode", + number: 12249, + url: "https://github.com/Kilo-Org/kilocode/pull/12249", + }, + { + repo: "Kilo-Org/kilocode", + number: 12481, + url: "https://github.com/Kilo-Org/kilocode/pull/12481", + }, + ], + ) + } + + { + const narrative = "Revert the mistaken fix in #99999 because it broke streams.\n\nReverts #12249." + const targets = parseRevertTargets(narrative, defaultRepo) + assert.equal(targets.length, 1) + assert.equal(targets[0].number, 12249) + assert.ok(!targets.some((t) => t.number === 99999)) + } + + { + const targets = parseRevertTargets("Reverts Kilo-Org/cloud#42.", defaultRepo) + assert.equal(targets.length, 1) + assert.equal(targets[0].repo, "Kilo-Org/cloud") + assert.equal(targets[0].number, 42) + assert.equal(targets[0].url, "https://github.com/Kilo-Org/cloud/pull/42") + } + + { + const targets = parseRevertTargets("Reverts #1, #2.", defaultRepo) + assert.equal(targets.length, 2) + assert.deepEqual( + targets.map((t) => t.number), + [1, 2], + ) + } + + assert.deepEqual(parseRevertTargets("This reverts commit deadbeefcafe.", defaultRepo), []) + assert.deepEqual(parseRevertTargets("This reverts #5.", defaultRepo), []) + assert.deepEqual(parseRevertTargets("No revert trailer here at all.", defaultRepo), []) + + // bulleted / quoted single-line trailers + { + const targets = parseRevertTargets("- Reverts #7.", defaultRepo) + assert.equal(targets.length, 1) + assert.equal(targets[0].number, 7) + } + { + const targets = parseRevertTargets("> Reverts #8.", defaultRepo) + assert.equal(targets.length, 1) + assert.equal(targets[0].number, 8) + } + // word-boundary: prose glued to "Reverts" is not a trailer + assert.deepEqual(parseRevertTargets("Revertsomething #5", defaultRepo), []) + + // --- computeRevertAnnotations --- + // Map keys are lowercased; lookups must use .toLowerCase() + const fUrl = "https://github.com/Kilo-Org/kilocode/pull/100" + const r1Url = "https://github.com/Kilo-Org/kilocode/pull/200" + const r2Url = "https://github.com/Kilo-Org/kilocode/pull/300" + const f2Url = "https://github.com/Kilo-Org/kilocode/pull/101" + const mergedAt = "2026-07-20T12:00:00.000Z" + const mergedAt2 = "2026-07-21T12:00:00.000Z" + + { + const annotations = computeRevertAnnotations([ + { + url: r1Url, + merged_at: mergedAt, + targets: [{ repo: "Kilo-Org/kilocode", number: 100, url: fUrl }], + }, + ]) + assert.equal(annotations.size, 1) + assert.deepEqual(annotations.get(fUrl.toLowerCase()), { url: r1Url, merged_at: mergedAt }) + } + + { + // two-target signal (#12497-shaped) + const annotations = computeRevertAnnotations([ + { + url: r1Url, + merged_at: mergedAt, + targets: [ + { repo: "Kilo-Org/kilocode", number: 100, url: fUrl }, + { repo: "Kilo-Org/kilocode", number: 101, url: f2Url }, + ], + }, + ]) + assert.equal(annotations.size, 2) + assert.deepEqual(annotations.get(fUrl.toLowerCase()), { url: r1Url, merged_at: mergedAt }) + assert.deepEqual(annotations.get(f2Url.toLowerCase()), { url: r1Url, merged_at: mergedAt }) + } + + { + // revert-of-revert: R1 reverts F, R2 reverts R1 → F not annotated; R1 gets R2 + const annotations = computeRevertAnnotations([ + { + url: r1Url, + merged_at: mergedAt, + targets: [{ repo: "Kilo-Org/kilocode", number: 100, url: fUrl }], + }, + { + url: r2Url, + merged_at: mergedAt2, + targets: [{ repo: "Kilo-Org/kilocode", number: 200, url: r1Url }], + }, + ]) + assert.equal(annotations.has(fUrl.toLowerCase()), false) + assert.deepEqual(annotations.get(r1Url.toLowerCase()), { url: r2Url, merged_at: mergedAt2 }) + } + + { + const annotations = computeRevertAnnotations([{ url: r1Url, merged_at: mergedAt, targets: [] }]) + assert.equal(annotations.size, 0) + } + + // --- applyRevertAnnotations --- + { + const digest = [ + { url: fUrl, title: "feat F" }, + { url: "https://github.com/Kilo-Org/kilocode/pull/999", title: "untouched" }, + ] + const applied = applyRevertAnnotations(digest, [ + { + url: r1Url, + merged_at: mergedAt, + targets: [{ repo: "Kilo-Org/kilocode", number: 100, url: fUrl }], + }, + ]) + assert.deepEqual(digest[0].reverted_by, { url: r1Url, merged_at: mergedAt }) + assert.equal(digest[1].reverted_by, undefined) + assert.deepEqual(applied, [[fUrl, r1Url]]) + } + + { + // end-to-end revert-of-revert: F must not gain reverted_by + const digest = [{ url: fUrl, title: "feat F" }] + const applied = applyRevertAnnotations(digest, [ + { + url: r1Url, + merged_at: mergedAt, + targets: [{ repo: "Kilo-Org/kilocode", number: 100, url: fUrl }], + }, + { + url: r2Url, + merged_at: mergedAt2, + targets: [{ repo: "Kilo-Org/kilocode", number: 200, url: r1Url }], + }, + ]) + assert.equal(digest[0].reverted_by, undefined) + assert.deepEqual(applied, []) + } + + { + // case-insensitive url matching: lowercase signal target vs canonical digest entry + const canonical = "https://github.com/Kilo-Org/kilocode/pull/12249" + const lowerTarget = "https://github.com/kilo-org/kilocode/pull/12249" + const reverter = "https://github.com/Kilo-Org/kilocode/pull/12497" + const digest = [{ url: canonical, title: "feat stream" }] + const applied = applyRevertAnnotations(digest, [ + { + url: reverter, + merged_at: mergedAt, + targets: [{ repo: "kilo-org/kilocode", number: 12249, url: lowerTarget }], + }, + ]) + assert.deepEqual(digest[0].reverted_by, { url: reverter, merged_at: mergedAt }) + assert.deepEqual(applied, [[canonical, reverter]]) + } + + // --- unannotatedRevertSignals --- + { + // partial coverage: F in-digest, G pre-window → only G missed; chains empty + const sUrl = "https://github.com/Kilo-Org/kilocode/pull/500" + const gUrl = "https://github.com/Kilo-Org/kilocode/pull/102" + const signals = [ + { + url: sUrl, + merged_at: mergedAt, + targets: [ + { repo: "Kilo-Org/kilocode", number: 100, url: fUrl }, + { repo: "Kilo-Org/kilocode", number: 102, url: gUrl }, + ], + }, + ] + const result = unannotatedRevertSignals(signals, [[fUrl, sUrl]]) + assert.deepEqual(result, { missed: [{ url: sUrl, targets: [gUrl] }], unparsed: [], chains: [] }) + } + + { + // fully covered signal (no chain) → all three buckets empty + const signals = [ + { + url: r1Url, + merged_at: mergedAt, + targets: [ + { repo: "Kilo-Org/kilocode", number: 100, url: fUrl }, + { repo: "Kilo-Org/kilocode", number: 101, url: f2Url }, + ], + }, + ] + const result = unannotatedRevertSignals(signals, [ + [fUrl, r1Url], + [f2Url, r1Url], + ]) + assert.deepEqual(result, { missed: [], unparsed: [], chains: [] }) + } + + { + // depth-2 chain (#4709/#4759): R1→F, R2→R1 — both in chains; F visible as R1 target + const signals = [ + { + url: r1Url, + merged_at: mergedAt, + targets: [{ repo: "Kilo-Org/kilocode", number: 100, url: fUrl }], + }, + { + url: r2Url, + merged_at: mergedAt2, + targets: [{ repo: "Kilo-Org/kilocode", number: 200, url: r1Url }], + }, + ] + const result = unannotatedRevertSignals(signals, []) + assert.deepEqual(result, { + missed: [], + unparsed: [], + chains: [ + { url: r1Url, targets: [fUrl] }, + { url: r2Url, targets: [r1Url] }, + ], + }) + } + + { + // depth-3 chain: R1→F, R2→R1, R3→R2 — all three in chains; missed/unparsed empty + const r3Url = "https://github.com/Kilo-Org/kilocode/pull/301" + const signals = [ + { + url: r1Url, + merged_at: mergedAt, + targets: [{ repo: "Kilo-Org/kilocode", number: 100, url: fUrl }], + }, + { + url: r2Url, + merged_at: mergedAt2, + targets: [{ repo: "Kilo-Org/kilocode", number: 200, url: r1Url }], + }, + { + url: r3Url, + merged_at: "2026-04-03T00:00:00Z", + targets: [{ repo: "Kilo-Org/kilocode", number: 201, url: r2Url }], + }, + ] + const result = unannotatedRevertSignals(signals, []) + assert.deepEqual(result, { + missed: [], + unparsed: [], + chains: [ + { url: r1Url, targets: [fUrl] }, + { url: r2Url, targets: [r1Url] }, + { url: r3Url, targets: [r2Url] }, + ], + }) + } + + { + // mixed signal: M targets [R1, A, B]; A annotated, B missed; chains lists only R1 + const mUrl = "https://github.com/Kilo-Org/kilocode/pull/800" + const aUrl = "https://github.com/Kilo-Org/kilocode/pull/801" + const bUrl = "https://github.com/Kilo-Org/kilocode/pull/802" + const signals = [ + { + url: r1Url, + merged_at: mergedAt, + targets: [{ repo: "Kilo-Org/kilocode", number: 100, url: fUrl }], + }, + { + url: mUrl, + merged_at: mergedAt2, + targets: [ + { repo: "Kilo-Org/kilocode", number: 200, url: r1Url }, + { repo: "Kilo-Org/kilocode", number: 801, url: aUrl }, + { repo: "Kilo-Org/kilocode", number: 802, url: bUrl }, + ], + }, + ] + const result = unannotatedRevertSignals(signals, [[aUrl, mUrl]]) + assert.deepEqual(result, { + missed: [{ url: mUrl, targets: [bUrl] }], + unparsed: [], + chains: [ + { url: r1Url, targets: [fUrl] }, + { url: mUrl, targets: [r1Url] }, + ], + }) + } + + { + // zero-target signal → unparsed, not missed; chains empty + const emptyUrl = "https://github.com/Kilo-Org/kilocode/pull/400" + const result = unannotatedRevertSignals([{ url: emptyUrl, merged_at: mergedAt, targets: [] }], []) + assert.deepEqual(result, { missed: [], unparsed: [emptyUrl], chains: [] }) + } + + { + // case-insensitivity: annotated set matches target urls differing only by case + const signalUrl = "https://github.com/Kilo-Org/kilocode/pull/600" + const targetMixed = "https://github.com/Kilo-Org/kilocode/pull/700" + const targetLower = "https://github.com/kilo-org/kilocode/pull/700" + const signals = [ + { + url: signalUrl, + merged_at: mergedAt, + targets: [{ repo: "Kilo-Org/kilocode", number: 700, url: targetMixed }], + }, + ] + const result = unannotatedRevertSignals(signals, [[targetLower, signalUrl]]) + assert.deepEqual(result, { missed: [], unparsed: [], chains: [] }) + } + + { + // live-window lock: #12497-shaped signal with both targets covered + const s12497 = "https://github.com/Kilo-Org/kilocode/pull/12497" + const t12249 = "https://github.com/Kilo-Org/kilocode/pull/12249" + const t12481 = "https://github.com/Kilo-Org/kilocode/pull/12481" + const signals = [ + { + url: s12497, + merged_at: mergedAt, + targets: [ + { repo: "Kilo-Org/kilocode", number: 12249, url: t12249 }, + { repo: "Kilo-Org/kilocode", number: 12481, url: t12481 }, + ], + }, + ] + const result = unannotatedRevertSignals(signals, [ + [t12249, s12497], + [t12481, s12497], + ]) + assert.deepEqual(result, { missed: [], unparsed: [], chains: [] }) + } + + // --- source-order guards (case-5 style) --- + { + const collectSrc = fs.readFileSync(COLLECT_SCRIPT, "utf8") + assert.ok(collectSrc.includes("./reverts.mjs"), "collect.mjs must import ./reverts.mjs") + const kindIdx = collectSrc.indexOf("revertTitleKind(item.title") + const dropIdx = collectSrc.indexOf("DROP_TITLE.test") + assert.ok(kindIdx >= 0, "revertTitleKind(item.title call missing") + assert.ok(dropIdx >= 0, "DROP_TITLE.test missing") + assert.ok(kindIdx < dropIdx, "revert interception must run before DROP_TITLE") + + const applyIdx = collectSrc.indexOf("applyRevertAnnotations(digest") + const fullIdx = collectSrc.indexOf("digest-full.json") + assert.ok(applyIdx >= 0, "applyRevertAnnotations(digest call missing") + assert.ok(fullIdx >= 0, "digest-full.json write missing") + assert.ok(applyIdx < fullIdx, "annotations must be applied before digests are written") + + assert.ok( + collectSrc.includes('if (revertTitleKind(item.title ?? "")) {'), + 'collect must use exact intercept line if (revertTitleKind(item.title ?? "")) {', + ) + } + + // --- prompt-text guards --- + { + const triagePrompt = fs.readFileSync(path.join(HERE, "triage-prompt.md"), "utf8") + assert.ok(triagePrompt.includes("reverted_by"), "triage-prompt must mention reverted_by") + assert.ok(triagePrompt.includes("stream-liveness"), "triage-prompt must mention stream-liveness") + assert.ok( + triagePrompt.includes("reverted by https://github.com/Kilo-Org/kilocode/pull/12497"), + "triage-prompt must contain exact cite example", + ) + + const editPrompt = fs.readFileSync(path.join(HERE, "edit-prompt.md"), "utf8") + assert.ok(editPrompt.includes("reverted_by"), "edit-prompt must mention reverted_by") + assert.ok(editPrompt.includes("current source tree"), "edit-prompt must mention current source tree") + assert.ok(editPrompt.includes("Skipping is a normal outcome"), "edit-prompt must mention skipping outcome") + assert.ok(editPrompt.includes("Existence alone is not enough"), "edit-prompt must mention existence guard") + } +} + // --------------------------------------------------------------------------- // main // --------------------------------------------------------------------------- @@ -1424,6 +1845,7 @@ function main() { case6_budgets, case7_cap, case8_triage, + case9_reverts, ] let failed = 0 for (const fn of cases) { diff --git a/.github/docs-sync/triage-prompt.md b/.github/docs-sync/triage-prompt.md index 10e54406e89..dbf2341e70f 100644 --- a/.github/docs-sync/triage-prompt.md +++ b/.github/docs-sync/triage-prompt.md @@ -4,11 +4,12 @@ The attached `digest.json` file contains PRs recently merged to Kilo-Org/cloud a A PR is docs-worthy ONLY if a user of Kilo Code would need to learn something new or change how they use the product after this PR ships. Examples: new commands, flags, settings, UI workflows, providers, pricing/limits changes, breaking behavior changes, or fixes that change documented behavior. -A PR is NOT docs-worthy when it is: an internal refactor, infrastructure or CI work, a feature-flag scaffold that is not yet user-visible, test or dependency work, a bug fix that merely restores already-documented behavior, or a change only visible to contributors or self-hosters. +A PR is NOT docs-worthy when it is: an internal refactor, infrastructure or CI work, a feature-flag scaffold that is not yet user-visible, test or dependency work, a bug fix that merely restores already-documented behavior, a change only visible to contributors or self-hosters, an internal technical fix or plumbing change with no user-visible behavior or workflow change (for example stream-liveness/watchdog plumbing, internal retry or timeout handling with no user-facing setting), or UI or rendering polish that does not change what a user must do or learn. Rules: - Include every input PR exactly once, identified by its `number` and `url`. Never invent PRs. +- A digest entry with a `reverted_by` field had its changes reverted by that later PR in the same window. Treat such PRs as not docs-worthy unless the entry's body or file list shows clear evidence the change was re-landed after the revert. When `reverted_by` drives your verdict, the `reason` must cite it, e.g. "reverted by https://github.com/Kilo-Org/kilocode/pull/12497". - When unsure, set `docs_worthy` to false and explain the doubt in `reason`. - `target_sections` is only filled for docs-worthy PRs. Use rough docs areas, e.g. `getting-started`, `code-with-ai/platforms/cli`, `code-with-ai/platforms/vscode`, `code-with-ai/agents`, `ai-providers`, `teams`, `enterprise`, `automate`. - `reason` is one short sentence, written for the human who reviews the final docs PR.