fix(docs-sync): intercept revert PRs and calibrate prompts (#12708)

* fix(docs-sync): calibrate triage/edit prompts against over-documentation

* fix(docs-sync): intercept revert PRs and annotate reverted digest entries

* fix(docs-sync): harden revert trailer parsing and surface unannotated reverts

* fix(docs-sync): report per-target unannotated reverts, excluding by-design cancellations

* fix(docs-sync): surface revert chains in the step summary without resolving them

* test(docs-sync): fix depth-3 chain fixture and split mixed chain signals correctly
This commit is contained in:
Igor Šćekić
2026-07-30 13:47:28 -05:00
committed by GitHub
parent e959534ad6
commit 9837b4d63a
5 changed files with 638 additions and 16 deletions
+56 -13
View File
@@ -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"))
+5 -2
View File
@@ -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:
+153
View File
@@ -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 <sha>.` (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<targetUrl, { url, merged_at }> — 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 }
}
+422
View File
@@ -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) {
+2 -1
View File
@@ -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.