mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-24 16:02:55 +08:00
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:
@@ -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"))
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user