mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-01 15:32:11 +08:00
Merge branch 'main' into docs/daoxe-provider-guide
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Apply the Agent Manager base branch picker selection to the active diff immediately. Changing the base branch now refreshes the diff against the new base instead of keeping the previous comparison until the scope or session changed.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": minor
|
||||
---
|
||||
|
||||
Add a scope selector and base branch picker to the Agent Manager diff review. The side panel and full-screen review now let you switch between Branch, Staged, Unstaged, and Session scopes for the selected worktree, and the Branch scope's base branch can be overridden from a picker next to it. Branch stays the default, so existing review behavior is unchanged.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
---
|
||||
|
||||
Inherit the current model and reasoning variant when Agent Manager starts sessions without explicit overrides.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": minor
|
||||
---
|
||||
|
||||
Add an experimental multi-project foundation for Agent Manager behind the Multi-Project Agent Manager toggle in Kilo Settings > Experimental. A persistent project registry catalogs additional Git repositories across restarts while the current workspace repository stays the pinned default project, and all repository-bound services (state, worktrees, setup scripts, stale tracking, polling) are now owned by immutable per-project contexts that can be activated, expanded, and switched through new project protocol messages. Every expanded project runs its own git stats and PR pollers, so all visible project accordions show live data at the same time; switching projects keeps previously visited projects expanded and restores the exact worktree or session that was open there. Sessions created anywhere (sidebar, another window, the CLI) appear in the owning project's list immediately, and open tabs stay private to the project they were opened in. Single-project behavior is unchanged when the experiment is disabled.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": minor
|
||||
---
|
||||
|
||||
Make the Agent Manager diff review follow the sidebar selection instead of a single session. Switching session tabs inside a worktree no longer refetches the Branch, Staged, and Unstaged scopes, the Session scope now swaps to the active session's changes on tab switch, and the Local tab gains the Session scope so sessions running in the workspace can be reviewed on their own. The Session scope shows a notice when snapshots are disabled instead of a blank list, worktrees without an open session now still show their branch diff, and the Apply dialog lists the worktree's changes again.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Fix Agent Manager embedded terminals not opening in the side panel or as a tab.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Make Cmd/Ctrl+/ toggle the Agent Manager terminal even when the webview keybinding forwarding drops the key while the prompt input is focused, and stop it from triggering the Agent Manager terminal while the Kilo sidebar is focused.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Match the Agent Manager terminal shortcut fallback to the platform modifier (Cmd on macOS, Ctrl elsewhere) and consume the extension echo once per keypress so unrelated invocations are no longer swallowed.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Fix the Agent Manager detail pane blanking when an unassigned session is selected. Clicking a session that belongs to no worktree now renders the chat and read-only banner again instead of an empty content area, and live terminal tabs stay mounted across the switch.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Keep the Agent Manager terminal cursor visible on the bottom row.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": minor
|
||||
---
|
||||
|
||||
Run Agent Manager project scripts in the terminal selected by the existing toolbar dropdown. Agent Manager panel uses the named side terminal, while VS Code terminal retains the integrated task flow.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
---
|
||||
|
||||
Preserve dynamic tool properties when removing unsupported regex lookarounds.
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
"@kilocode/kilo-indexing": patch
|
||||
---
|
||||
|
||||
Wait for the primary codebase index before indexing a linked worktree, preventing large worktrees from consuming excessive CPU during startup.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"kilo-code": minor
|
||||
---
|
||||
|
||||
Add in-chat search to the VS Code sidebar and editor tabs. Click the search icon in the session header to find text across the current conversation, with match case, whole word, and regular expression options, then step through highlighted matches with the next/previous controls.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
---
|
||||
|
||||
Make session model usage easier to scan with collapsible summary rows and aligned steps and cost columns.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
"@kilocode/kilo-telemetry": patch
|
||||
---
|
||||
|
||||
Reduce CLI startup time by deferring Kilo-specific module loading until commands actually run, caching the telemetry profile lookup across invocations, and uploading telemetry in the background so process exit is not delayed by a network round trip
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
---
|
||||
|
||||
Fix settings snapping back to their previous value after being cleared to "Not set" when multiple config files exist (e.g. both `kilo.json` and `kilo.jsonc`)
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
---
|
||||
|
||||
Mark Kilo Console as deprecated and direct users to supported session and settings workflows.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Allow users to enable web search for models from all providers through Kilo configuration, VS Code settings, and Kilo Console settings.
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
"@kilocode/cli": patch
|
||||
---
|
||||
|
||||
Speed up VS Code settings saves by draining pending prompts and disposing worktree instances concurrently.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
---
|
||||
|
||||
Resolve AWS Bedrock credentials from SSO profiles in packaged CLI builds.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
---
|
||||
|
||||
Use the correct `filePath` argument name in the Gemini system prompt.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
---
|
||||
|
||||
Fix Gemma 4 models failing with "thinkingLevel not supported" when using Google AI Studio.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
---
|
||||
|
||||
Install the latest stable CLI release when newer non-CLI or prerelease releases exist.
|
||||
@@ -1,7 +0,0 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
---
|
||||
|
||||
Fix `kilo upgrade` for curl installs resolving the wrong latest version
|
||||
|
||||
The upgrade command's version resolution for curl-detected installations used GitHub's `/releases/latest` endpoint, which now returns JetBrains plugin releases (e.g. `jetbrains/v7.0.4`) instead of the latest CLI release. This caused `kilo upgrade` to fail for curl installs. Version resolution now uses the npm `latest` dist-tag, matching the install script fix.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
---
|
||||
|
||||
Keep RC installations up to date when a newer stable CLI release is published.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/kilo-memory": patch
|
||||
---
|
||||
|
||||
Accept memory digest responses that include additional model-generated fields.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
---
|
||||
|
||||
Report Bash commands terminated by a signal with the conventional 128 + signum exit code (e.g. 139 for SIGSEGV) instead of hanging until the command timeout.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Persist MCP server enable and disable changes from VS Code settings across window reloads.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
---
|
||||
|
||||
Show the Kilo Gateway rate-limit message when login has too many pending authorization requests.
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
"@kilocode/cli": patch
|
||||
---
|
||||
|
||||
Show troubleshooting and migration guidance when Google Gemini rejects API credentials.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
---
|
||||
|
||||
Prevent Gemini requests from failing when MCP tool schemas contain `required` fields without matching object properties.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
---
|
||||
|
||||
Allow GPT-5.6 models to use tools whose JSON schemas contain regex lookarounds.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
---
|
||||
|
||||
Block environment and out-of-project file substitutions in project markdown configuration.
|
||||
@@ -0,0 +1,13 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
---
|
||||
|
||||
Non-interactive `kilo run` no longer reports success for runs that did not complete. A plain
|
||||
headless run (neither `--auto` nor `--dangerously-skip-permissions`) in which the CLI
|
||||
auto-rejected at least one permission ask now exits 1 with a stderr diagnostic naming the cause,
|
||||
and a run whose session errors mid-stream now prints that diagnostic to stderr under
|
||||
`--format json` as well (previously the JSON branch swallowed it). Runs that complete their turn
|
||||
with no auto-rejected permission still exit 0. Under `--format json` the auto-reject path adds a
|
||||
new `error` event to the stream; existing event shapes are unchanged. The same exit-1 rule applies
|
||||
to a plain non-interactive `--attach` run that auto-rejects an ask (that run was equally crippled);
|
||||
interactive mode is untouched.
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Hide gpt-5.5-pro from the model picker when using ChatGPT OAuth login, since Codex rejects it with HTTP 400.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@kilocode/kilo-jetbrains": patch
|
||||
---
|
||||
|
||||
Return keyboard focus to the JetBrains prompt after clicking inline session dialog actions.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@kilocode/kilo-jetbrains": patch
|
||||
---
|
||||
|
||||
Stop orphaned Kilo CLI processes when JetBrains IDEs close, including binaries that ignore graceful shutdown.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@kilocode/kilo-jetbrains": patch
|
||||
---
|
||||
|
||||
Refresh the JetBrains prompt input chrome when switching IDE themes.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@kilocode/kilo-jetbrains": patch
|
||||
---
|
||||
|
||||
Polish JetBrains prompt focus and copy toolbar positioning.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@kilocode/kilo-jetbrains": patch
|
||||
---
|
||||
|
||||
Show rollback/redo progress inline (on the message and redo controls) with a cancel action instead of a full-screen loading overlay.
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
"@kilocode/kilo-jetbrains": patch
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Support rollback and redo controls in JetBrains sessions and clarify when reverted changes can be redone.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@kilocode/kilo-jetbrains": patch
|
||||
---
|
||||
|
||||
Clarify in JetBrains rollback that only the conversation was reverted when snapshots are disabled.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/kilo-jetbrains": minor
|
||||
---
|
||||
|
||||
Improve JetBrains session change tracking: show the files each assistant turn modified with expandable per-file diffs, open inline and branch diffs in a refreshable diff viewer, and surface branch changes in the session header.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@kilocode/kilo-jetbrains": patch
|
||||
---
|
||||
|
||||
Polish JetBrains session message toolbar alignment, rollback icon, and copy tooltips.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@kilocode/kilo-jetbrains": patch
|
||||
---
|
||||
|
||||
Increase spacing before non-initial user prompts in the JetBrains session transcript.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Load expanded diff previews consistently when opening a review.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"kilo-code": minor
|
||||
---
|
||||
|
||||
Open multiple same-repository sessions as tabs from Kilo sidebar and editor-tab chats.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Navigate long conversations from a compact prompt rail that loads earlier history as you scroll.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Fix the `@` "Past chats" picker in Agent Manager showing only the current session's directory. It now lists previous sessions across the whole worktree family — the local workspace and every Agent Manager worktree — each labeled with its worktree name, matching the Agent Manager session search. Any listed session can be attached as context, including chats from other worktrees of the same repository.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": minor
|
||||
---
|
||||
|
||||
Add Persian (Farsi) as a UI language, including right-to-left layout. Contributed by Babak Safabahar.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
---
|
||||
|
||||
Allow clearing a nested project setting when no project config file exists yet.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Show effective permission defaults and clarify external-directory approvals in VS Code.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Open an embedded terminal automatically when switching to a worktree without one.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
---
|
||||
|
||||
Stop explicitly directing GPT and Codex models to delegate tasks to subagents.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
---
|
||||
|
||||
Show the provider's actual error message when an OpenAI or Azure Responses API stream fails (for example an upstream rate limit) instead of a generic retry notice.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
---
|
||||
|
||||
Include the underlying reason in search execution failures instead of showing a bare "ripgrep execution failed" message.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Show sandbox controls only after sandboxing is enabled in settings.
|
||||
@@ -1,7 +0,0 @@
|
||||
---
|
||||
"kilo-code": minor
|
||||
"@kilocode/cli": minor
|
||||
"@kilocode/sdk": minor
|
||||
---
|
||||
|
||||
Support configuring network destinations that sandboxed tools can reach while network access is otherwise restricted.
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Shut down the headless `kilo serve` process automatically when the editor client that launched it exits without a clean signal, preventing orphaned CLI processes.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Make Agent Manager panel terminals behave like session tabs: right-click Close and Close Others, arrow-key tab navigation, overflow scrolling with edge fades, and stable tab widths while closing. The new-terminal button now sits directly next to the last terminal tab instead of the far edge of the panel.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/cli": minor
|
||||
---
|
||||
|
||||
Support executing shell commands embedded in skill files. Commands written as `` !`command` `` in a SKILL.md run and their output is inlined into the skill. Only trusted skills can run commands and `KILO_DISABLE_SKILL_SHELL` disables the behavior; when the model loads a skill, the commands are shown in a single up-front approval before running.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": minor
|
||||
---
|
||||
|
||||
Show skill shell commands in the permission prompt. When a skill runs embedded shell commands, the approval dialog now lists every command with Allow / Reject.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": minor
|
||||
---
|
||||
|
||||
Run Agent Manager worktree setup scripts in the terminal selected by the toolbar dropdown. Agent Manager panel shows live setup output in a named `Setup` side tab that reveals itself while provisioning and hides again on success unless you engaged with the panel, while VS Code terminal retains the integrated task flow.
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
"@kilocode/kilo-ui": patch
|
||||
---
|
||||
|
||||
Show detailed GPT-5.6 reasoning summaries and avoid expandable blank panels when a provider returns only a summary title.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/kilo-telemetry": patch
|
||||
---
|
||||
|
||||
Include the host operating system name, version, and architecture in telemetry events.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"kilo-code": minor
|
||||
---
|
||||
|
||||
Add a "Browse files..." option to the @ mention dropdown in the VS Code extension prompt input. Selecting it opens a native file picker and mentions the chosen file, so you can point Kilo Code at files outside the current workspace. Files outside the workspace are not auto-attached; Kilo Code reads them on request through the normal Read tool, respecting your file access permissions.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Start Agent Manager local sessions on Windows when workspace drive-letter casing differs.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
---
|
||||
|
||||
Retry transient locked-file errors (EPERM/EACCES/EBUSY) on Windows when atomically saving config and other files. Background plugin installs and Windows Defender/indexer can briefly hold the temp file during the rename step, which previously surfaced as a 500 error. A short backoff now retries the rename so config writes succeed without surfacing the contention.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Show a write-aware cache hit rate in the VS Code session usage bar.
|
||||
@@ -35,3 +35,7 @@ packages/kilo-i18n/src/de.ts linguist-generated=false
|
||||
# Auto-generated CLI reference docs
|
||||
packages/kilo-docs/markdoc/partials/cli-commands-table.md linguist-generated=true
|
||||
packages/kilo-docs/pages/code-with-ai/platforms/cli-reference.md linguist-generated=true
|
||||
|
||||
# Upstream generated migration artifacts
|
||||
packages/core/migration/**/snapshot.json linguist-generated
|
||||
packages/core/src/database/migration.gen.ts linguist-generated
|
||||
|
||||
+2
-4
@@ -1,5 +1,3 @@
|
||||
# web + desktop packages
|
||||
packages/app/ @adamdotdevin
|
||||
packages/tauri/ @adamdotdevin
|
||||
packages/desktop/src-tauri/ @brendonovich
|
||||
packages/desktop/ @adamdotdevin
|
||||
packages/app/ @Hona @Brendonovich
|
||||
packages/desktop/ @Hona @Brendonovich
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
// kilocode_change - new file
|
||||
|
||||
/**
|
||||
* Collects PRs merged to the source repos since the watermark, applies a
|
||||
* deterministic pre-filter, and writes docs-sync-out/digest.json for the LLM
|
||||
* triage pass.
|
||||
*
|
||||
* Pre-filter drops (triage never sees these):
|
||||
* - PRs labeled auto-docs (this bot's own rolling PRs)
|
||||
* - 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"
|
||||
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$)/
|
||||
|
||||
function argSince() {
|
||||
const i = process.argv.indexOf("--since")
|
||||
const v = i >= 0 ? process.argv[i + 1] : null
|
||||
if (!v || Number.isNaN(new Date(v).getTime())) {
|
||||
throw new Error("usage: collect.mjs --since <ISO date>")
|
||||
}
|
||||
return new Date(v)
|
||||
}
|
||||
|
||||
async function mergedPrs(fullRepo, since) {
|
||||
const query = `repo:${fullRepo} is:pr is:merged merged:>=${since.toISOString()}`
|
||||
return searchIssues(query)
|
||||
}
|
||||
|
||||
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, revert: 0 }
|
||||
const reverts = []
|
||||
|
||||
for (const fullRepo of SOURCE_REPOS) {
|
||||
const prs = await mergedPrs(fullRepo, since)
|
||||
console.log(`${fullRepo}: ${prs.length} merged PRs in window`)
|
||||
|
||||
for (const item of prs) {
|
||||
const author = item.user?.login ?? ""
|
||||
if ((item.labels ?? []).some((l) => l.name === "auto-docs")) {
|
||||
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
|
||||
}
|
||||
|
||||
const number = item.number
|
||||
let pr
|
||||
let files
|
||||
try {
|
||||
pr = await api(`/repos/${fullRepo}/pulls/${number}`)
|
||||
files = await listPrFiles(fullRepo, number)
|
||||
} catch (err) {
|
||||
// Isolate per-PR failures: one dead PR must not abort the whole run.
|
||||
console.warn(`::warning::skipping ${fullRepo}#${number}: ${err.message}`)
|
||||
dropped.fetch_error++
|
||||
continue
|
||||
}
|
||||
// listPrFiles caps at 300 files; a truncated list can't support the
|
||||
// docs-only classification, so keep such PRs and record the true total.
|
||||
const truncated = files.length >= 300
|
||||
if (!truncated && files.length > 0 && files.every((f) => DOCS_ONLY_PATH.test(f.filename))) {
|
||||
dropped.docs_only++
|
||||
continue
|
||||
}
|
||||
|
||||
let patch = ""
|
||||
for (const f of files) {
|
||||
if (!f.patch) continue
|
||||
const chunk = `--- ${f.filename}\n${f.patch}\n`
|
||||
if (patch.length + chunk.length > PATCH_LIMIT) {
|
||||
patch += "\n... (diff truncated) ...\n"
|
||||
break
|
||||
}
|
||||
patch += chunk
|
||||
}
|
||||
|
||||
digest.push({
|
||||
repo: fullRepo,
|
||||
number,
|
||||
title: pr.title,
|
||||
url: pr.html_url,
|
||||
author,
|
||||
merged_at: pr.merged_at,
|
||||
labels: (pr.labels ?? []).map((l) => l.name),
|
||||
body: (pr.body ?? "").slice(0, BODY_LIMIT),
|
||||
files: files.slice(0, FILE_LIMIT).map((f) => `${f.status} ${f.filename} (+${f.additions}/-${f.deletions})`),
|
||||
files_total: pr.changed_files ?? files.length,
|
||||
patch_excerpt: patch,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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 })
|
||||
// Full digest (bodies + patch excerpts) is filtered down to docs-worthy PRs
|
||||
// for the edit pass; the slim digest keeps the triage pass context small.
|
||||
fs.writeFileSync(`${OUT_DIR}/digest-full.json`, JSON.stringify(digest, null, 2))
|
||||
const slim = digest.map(({ patch_excerpt, body, ...rest }) => ({
|
||||
...rest,
|
||||
body: body.slice(0, SLIM_BODY_LIMIT),
|
||||
}))
|
||||
fs.writeFileSync(`${OUT_DIR}/digest.json`, JSON.stringify(slim, null, 2))
|
||||
|
||||
console.log(`kept ${digest.length} PRs, dropped:`, dropped)
|
||||
appendOutput("count", digest.length)
|
||||
appendOutput("digest", `${OUT_DIR}/digest.json`)
|
||||
|
||||
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"))
|
||||
@@ -0,0 +1,27 @@
|
||||
You are the Kilo Code documentation bot. You update the public product documentation in `packages/kilo-docs` (a Markdoc/Next.js site served at kilo.ai/docs) so it reflects recently merged PRs. You are handling one batch of PRs; the batch files and your output file are named at the end of these instructions.
|
||||
|
||||
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, 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 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:
|
||||
|
||||
- Only create or modify files under `packages/kilo-docs/`. Never touch code, tests, config, images, or anything outside that directory.
|
||||
- Never remove or rename pages. Never document unreleased behavior. Never copy internal PR discussion into the docs; write user-facing documentation.
|
||||
- Do not run git commands and do not commit anything; automation handles git.
|
||||
- Keep the change small and precise. Do not rewrite sections that are already accurate.
|
||||
|
||||
When finished, write the summary JSON file named in the batch specifics below: a JSON array with exactly one entry per batch PR, consumed by automation (this file is never committed). Use `action` values like `updated <path>`, `created <path>`, or `skipped`. Example:
|
||||
|
||||
[{"pr": 123, "url": "https://github.com/Kilo-Org/kilocode/pull/123", "action": "updated pages/code-with-ai/platforms/cli.md", "reason": "documented --variant flag"}, {"pr": 124, "url": "https://github.com/Kilo-Org/kilocode/pull/124", "action": "skipped", "reason": "feature behind unreleased flag"}]
|
||||
@@ -0,0 +1,203 @@
|
||||
// kilocode_change - new file
|
||||
|
||||
/**
|
||||
* Runs the LLM edit pass over docs-sync-out/worthy.json in batches.
|
||||
*
|
||||
* Batching bounds each `kilo run` context (a replay window can yield dozens
|
||||
* of docs-worthy PRs with large diffs). Each batch gets its own CLI session
|
||||
* and writes its own summary file; results are merged into
|
||||
* docs-sync-out/edit-summary.json. A batch that fails or is deferred by the
|
||||
* wall-clock budget is recorded as action "pending" so the watermark holds
|
||||
* back and the next run re-collects those PRs.
|
||||
*
|
||||
* Env: EDIT_MODEL (provider/model), KILO_API_KEY + KILO_ORG_ID (set by workflow; read natively by the kilo provider).
|
||||
* Budgets: EDIT_BUDGET_MINUTES (default 50), EDIT_BATCH_TIMEOUT_MINUTES (default 15).
|
||||
* Test hook: DOCS_SYNC_BACKOFF_MS replaces every retry wait when set.
|
||||
*/
|
||||
|
||||
import fs from "node:fs"
|
||||
import path from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { backoffMsForAttempt, deadline, remainingMs, runKilo, sleepSync } from "./lib.mjs"
|
||||
|
||||
const BATCH_SIZE = 5
|
||||
const ATTEMPTS = 3
|
||||
const OUT_DIR = "docs-sync-out"
|
||||
export const SUMMARY_FILE = ".docs-sync-summary.json"
|
||||
|
||||
const HERE = path.dirname(fileURLToPath(import.meta.url))
|
||||
const basePrompt = fs.readFileSync(path.join(HERE, "edit-prompt.md"), "utf8")
|
||||
const model = process.env.EDIT_MODEL
|
||||
if (!model) throw new Error("EDIT_MODEL is required")
|
||||
|
||||
const EDIT_BUDGET_MINUTES = Number(process.env.EDIT_BUDGET_MINUTES) || 50
|
||||
const EDIT_BATCH_TIMEOUT_MINUTES = Number(process.env.EDIT_BATCH_TIMEOUT_MINUTES) || 15
|
||||
const BATCH_TIMEOUT_MS = EDIT_BATCH_TIMEOUT_MINUTES * 60 * 1000
|
||||
|
||||
const worthy = JSON.parse(fs.readFileSync(`${OUT_DIR}/worthy.json`, "utf8"))
|
||||
const triage = JSON.parse(fs.readFileSync(`${OUT_DIR}/triage.json`, "utf8"))
|
||||
const priority = new Map(triage.map((e) => [e.url, e]))
|
||||
const ordered = [...worthy].sort((a, b) => {
|
||||
const rank = { high: 0, medium: 1, low: 2 }
|
||||
return (rank[priority.get(a.url)?.priority] ?? 1) - (rank[priority.get(b.url)?.priority] ?? 1)
|
||||
})
|
||||
|
||||
/** @type {Map<string, string>} url → pending cause for failed/deferred batches */
|
||||
const pendingCauses = new Map()
|
||||
|
||||
function formatCause(result) {
|
||||
const bits = []
|
||||
if (result.timedOut) bits.push("timed out")
|
||||
if (result.exitCode !== null && result.exitCode !== undefined) bits.push(`exit ${result.exitCode}`)
|
||||
if (result.stderrTail) bits.push(result.stderrTail.replaceAll("\n", " ").slice(0, 200))
|
||||
return bits.join("; ") || "no diagnostic"
|
||||
}
|
||||
|
||||
function editBatch(batch, index, budgetDeadline) {
|
||||
const batchFile = `${OUT_DIR}/edit-batch-${index}.json`
|
||||
const triageFile = `${OUT_DIR}/edit-batch-triage-${index}.json`
|
||||
const summaryFile = `${OUT_DIR}/edit-summary-${index}.json`
|
||||
fs.writeFileSync(batchFile, JSON.stringify(batch, null, 2))
|
||||
fs.writeFileSync(
|
||||
triageFile,
|
||||
JSON.stringify(
|
||||
batch.map((d) => priority.get(d.url)).filter(Boolean),
|
||||
null,
|
||||
2,
|
||||
),
|
||||
)
|
||||
|
||||
const prompt = `${basePrompt}
|
||||
|
||||
Batch specifics for this run: the PRs to handle are in the attached ${batchFile} (full details) and ${triageFile} (triage verdicts). Handle ONLY the PRs in these batch files. When finished, write your per-PR results in the summary JSON format described above to the file \`${summaryFile}\` (path relative to the repository root).`
|
||||
|
||||
let lastCause = "edit pass failed"
|
||||
for (let attempt = 1; attempt <= ATTEMPTS; attempt++) {
|
||||
const left = remainingMs(budgetDeadline)
|
||||
if (left < BATCH_TIMEOUT_MS) {
|
||||
lastCause = `edit budget exhausted before batch ${index} attempt ${attempt} (${Math.ceil(left / 1000)}s left, need ${EDIT_BATCH_TIMEOUT_MINUTES}m)`
|
||||
console.warn(
|
||||
`batch ${index}: stopping retries — remaining budget cannot fit another ${EDIT_BATCH_TIMEOUT_MINUTES}m attempt`,
|
||||
)
|
||||
break
|
||||
}
|
||||
|
||||
// Headless `kilo run` auto-rejects every permission ask; without --auto the
|
||||
// agent cannot run shell commands. SECURITY: --auto grants unrestricted bash
|
||||
// to an agent steered by external PR content. Hardening deferred: a scoped
|
||||
// permission.bash map via KILO_CONFIG_CONTENT should replace --auto once the
|
||||
// required shell patterns are stable (see PR #12605 review thread).
|
||||
const result = runKilo({
|
||||
args: ["run", "--auto", prompt, "-m", model, "--variant", "high", "--dir", process.cwd(), "-f", batchFile, "-f", triageFile],
|
||||
timeoutMs: Math.min(BATCH_TIMEOUT_MS, left),
|
||||
streamStdout: true,
|
||||
label: `edit batch ${index} attempt ${attempt}`,
|
||||
})
|
||||
|
||||
if (fs.existsSync(summaryFile)) return true
|
||||
// Tolerate the agent dropping the docs-sync-out/ prefix.
|
||||
const alt = path.basename(summaryFile)
|
||||
if (fs.existsSync(alt)) {
|
||||
fs.renameSync(alt, summaryFile)
|
||||
return true
|
||||
}
|
||||
|
||||
// Exit 0 is not success: missing summary is a failure logged WITH the
|
||||
// captured stderrTail and exit code on every attempt.
|
||||
const cause = formatCause(result)
|
||||
lastCause = `edit batch ${index}: ${cause}`
|
||||
console.warn(
|
||||
`batch ${index} attempt ${attempt}: summary file ${summaryFile} not produced` +
|
||||
` (exit ${result.exitCode}${result.timedOut ? ", timed out" : ""})` +
|
||||
(result.stderrTail ? `\nstderr tail:\n${result.stderrTail}` : "\nstderr tail: (empty)"),
|
||||
)
|
||||
|
||||
if (attempt < ATTEMPTS) {
|
||||
const wait = backoffMsForAttempt(attempt)
|
||||
// Skip the wait when the remaining budget cannot fit another attempt.
|
||||
const afterWait = remainingMs(budgetDeadline) - wait
|
||||
if (wait > 0 && afterWait >= BATCH_TIMEOUT_MS) {
|
||||
console.warn(`batch ${index}: backing off ${wait / 1000}s before attempt ${attempt + 1}`)
|
||||
sleepSync(wait)
|
||||
} else if (wait > 0) {
|
||||
console.warn(
|
||||
`batch ${index}: skipping backoff — remaining budget cannot fit attempt ${attempt + 1} after wait`,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.warn(`::warning::edit batch ${index} failed after up to ${ATTEMPTS} attempts; ${batch.length} PRs pending`)
|
||||
for (const d of batch) pendingCauses.set(d.url, lastCause)
|
||||
return false
|
||||
}
|
||||
|
||||
const batches = []
|
||||
for (let i = 0; i < ordered.length; i += BATCH_SIZE) {
|
||||
batches.push(ordered.slice(i, i + BATCH_SIZE))
|
||||
}
|
||||
console.log(`editing docs for ${ordered.length} PRs in ${batches.length} batches of up to ${BATCH_SIZE}`)
|
||||
|
||||
const budgetDeadline = deadline(EDIT_BUDGET_MINUTES)
|
||||
let deferredFrom = -1
|
||||
|
||||
for (let i = 0; i < batches.length; i++) {
|
||||
const left = remainingMs(budgetDeadline)
|
||||
if (left < BATCH_TIMEOUT_MS) {
|
||||
deferredFrom = i
|
||||
const deferredPrs = batches.slice(i).reduce((n, b) => n + b.length, 0)
|
||||
console.warn(
|
||||
`stopping edit pass before batch ${i}: remaining budget (${Math.ceil(left / 1000)}s) cannot fit a ${EDIT_BATCH_TIMEOUT_MINUTES}m batch; deferring ${deferredPrs} PRs`,
|
||||
)
|
||||
const cause = `edit budget exhausted before batch ${i} (${Math.ceil(left / 1000)}s left)`
|
||||
for (let j = i; j < batches.length; j++) {
|
||||
for (const d of batches[j]) pendingCauses.set(d.url, cause)
|
||||
}
|
||||
break
|
||||
}
|
||||
editBatch(batches[i], i, budgetDeadline)
|
||||
}
|
||||
|
||||
if (deferredFrom >= 0) {
|
||||
console.warn(
|
||||
`edit pass deferred ${batches.slice(deferredFrom).reduce((n, b) => n + b.length, 0)} PRs due to wall-clock budget`,
|
||||
)
|
||||
}
|
||||
|
||||
// Merge batch summaries. Coverage: every worthy PR gets an entry so the PR
|
||||
// body accounts for it; failed/deferred batches show up as pending (not skipped).
|
||||
const merged = []
|
||||
const seen = new Set()
|
||||
for (let i = 0; i < batches.length; i++) {
|
||||
const file = `${OUT_DIR}/edit-summary-${i}.json`
|
||||
let entries = []
|
||||
try {
|
||||
entries = JSON.parse(fs.readFileSync(file, "utf8"))
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
for (const e of entries) {
|
||||
const url = String(e?.url ?? "")
|
||||
if (!url.startsWith("http") || seen.has(url)) continue
|
||||
seen.add(url)
|
||||
merged.push({
|
||||
pr: Number(e.pr) || 0,
|
||||
url,
|
||||
action: String(e.action ?? "skipped"),
|
||||
reason: String(e.reason ?? ""),
|
||||
})
|
||||
}
|
||||
}
|
||||
for (const d of ordered) {
|
||||
if (seen.has(d.url)) continue
|
||||
const cause = pendingCauses.get(d.url) || "edit pass failed or timed out for this PR"
|
||||
merged.push({ pr: d.number, url: d.url, action: "pending", reason: cause })
|
||||
}
|
||||
|
||||
// upsert-pr.mjs consumes the merged summary from the repo root; the file is
|
||||
// removed there before committing so it never lands in the docs PR.
|
||||
fs.writeFileSync(SUMMARY_FILE, JSON.stringify(merged, null, 2))
|
||||
const changed = merged.filter((e) => e.action !== "skipped" && e.action !== "pending").length
|
||||
const skipped = merged.filter((e) => e.action === "skipped").length
|
||||
const pending = merged.filter((e) => e.action === "pending").length
|
||||
console.log(`edit pass complete: ${changed} changed, ${skipped} skipped, ${pending} pending`)
|
||||
@@ -0,0 +1,76 @@
|
||||
// kilocode_change - new file
|
||||
|
||||
/**
|
||||
* Extracts and validates the triage JSON array from raw LLM stdout.
|
||||
* Usage: extract-json.mjs <raw-input-file> <output-file>
|
||||
* Exit 0 on success, 1 on any failure. Also exports parseTriageEntries for
|
||||
* the chunked triage runner.
|
||||
*/
|
||||
|
||||
import fs from "node:fs"
|
||||
import { pathToFileURL } from "node:url"
|
||||
|
||||
/** Returns validated triage entries, or null when extraction fails. */
|
||||
export function parseTriageEntries(raw) {
|
||||
// `kilo run` prints the assistant message twice (streaming render + final
|
||||
// summary), so stdout can hold the same array back-to-back. Try each "["
|
||||
// from the right and return the first slice that parses — i.e. the last
|
||||
// (most recent) valid array in the output.
|
||||
const end = raw.lastIndexOf("]")
|
||||
if (end < 0) return null
|
||||
|
||||
const starts = []
|
||||
for (let i = 0; i <= end; i++) {
|
||||
if (raw[i] === "[") starts.push(i)
|
||||
}
|
||||
|
||||
for (let s = starts.length - 1; s >= 0; s--) {
|
||||
let parsed
|
||||
try {
|
||||
parsed = JSON.parse(raw.slice(starts[s], end + 1))
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
if (!Array.isArray(parsed)) continue
|
||||
const entries = validate(parsed)
|
||||
if (entries) return entries
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function validate(parsed) {
|
||||
const entries = []
|
||||
for (const e of parsed) {
|
||||
const pr = Number(e?.pr)
|
||||
const url = String(e?.url ?? "")
|
||||
if (!Number.isInteger(pr) || !url.startsWith("http")) continue
|
||||
entries.push({
|
||||
pr,
|
||||
url,
|
||||
docs_worthy: e.docs_worthy === true,
|
||||
reason: String(e.reason ?? ""),
|
||||
target_sections: Array.isArray(e.target_sections) ? e.target_sections.map(String) : [],
|
||||
priority: ["high", "medium", "low"].includes(e.priority) ? e.priority : "medium",
|
||||
})
|
||||
}
|
||||
return entries.length > 0 ? entries : null
|
||||
}
|
||||
|
||||
function main() {
|
||||
const [, , inputPath, outputPath] = process.argv
|
||||
if (!inputPath || !outputPath) {
|
||||
console.error("usage: extract-json.mjs <raw-input-file> <output-file>")
|
||||
process.exit(1)
|
||||
}
|
||||
const entries = parseTriageEntries(fs.readFileSync(inputPath, "utf8"))
|
||||
if (!entries) {
|
||||
console.error("no valid triage JSON array found in input")
|
||||
process.exit(1)
|
||||
}
|
||||
fs.writeFileSync(outputPath, JSON.stringify(entries, null, 2))
|
||||
console.log(`extracted ${entries.length} triage entries (${entries.filter((e) => e.docs_worthy).length} docs-worthy)`)
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
main()
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
// kilocode_change - new file
|
||||
|
||||
/**
|
||||
* Filters the full digest down to PRs the triage pass marked docs-worthy.
|
||||
* Usage: filter-worthy.mjs <digest-full.json> <triage.json> <output.json>
|
||||
* The edit pass consumes the output so its context stays small.
|
||||
*/
|
||||
|
||||
import fs from "node:fs"
|
||||
|
||||
const [, , digestPath, triagePath, outputPath] = process.argv
|
||||
if (!digestPath || !triagePath || !outputPath) {
|
||||
console.error("usage: filter-worthy.mjs <digest-full.json> <triage.json> <output.json>")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const digest = JSON.parse(fs.readFileSync(digestPath, "utf8"))
|
||||
const triage = JSON.parse(fs.readFileSync(triagePath, "utf8"))
|
||||
|
||||
const worthy = new Set(triage.filter((e) => e.docs_worthy).map((e) => e.url))
|
||||
const out = digest.filter((d) => worthy.has(d.url))
|
||||
|
||||
fs.writeFileSync(outputPath, JSON.stringify(out, null, 2))
|
||||
console.log(`${out.length} of ${digest.length} digest entries are docs-worthy`)
|
||||
@@ -0,0 +1,266 @@
|
||||
// kilocode_change - new file
|
||||
|
||||
/**
|
||||
* Shared helpers for the docs-sync bot scripts. Dependency-free (Node 20+
|
||||
* global fetch) so the workflow does not rely on runner images shipping the
|
||||
* gh CLI.
|
||||
*/
|
||||
|
||||
import { spawnSync } from "node:child_process"
|
||||
import fs from "node:fs"
|
||||
|
||||
const API = "https://api.github.com"
|
||||
const MAX_RETRIES = 3
|
||||
|
||||
export function token() {
|
||||
const t = process.env.GH_TOKEN || process.env.GITHUB_TOKEN
|
||||
if (!t) throw new Error("GH_TOKEN (or GITHUB_TOKEN) is required")
|
||||
return t
|
||||
}
|
||||
|
||||
export function repo() {
|
||||
const r = process.env.GITHUB_REPOSITORY
|
||||
if (!r) throw new Error("GITHUB_REPOSITORY is required")
|
||||
return r
|
||||
}
|
||||
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
||||
|
||||
export async function api(path, { method = "GET", body } = {}) {
|
||||
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
|
||||
let res
|
||||
try {
|
||||
res = await fetch(`${API}${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
authorization: `Bearer ${token()}`,
|
||||
accept: "application/vnd.github+json",
|
||||
"x-github-api-version": "2022-11-28",
|
||||
"user-agent": "kilo-docs-sync-bot",
|
||||
},
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
})
|
||||
} catch (err) {
|
||||
if (attempt < MAX_RETRIES) {
|
||||
console.warn(`network error (${err.message}), retrying in ${5 * attempt}s`)
|
||||
await sleep(5000 * attempt)
|
||||
continue
|
||||
}
|
||||
throw err
|
||||
}
|
||||
|
||||
if (res.status === 403) {
|
||||
const text = await res.text()
|
||||
if (text.includes("rate limit") && attempt < MAX_RETRIES) {
|
||||
const retryAfter = Number(res.headers.get("retry-after")) || 30
|
||||
console.warn(`rate limited, retrying in ${retryAfter}s`)
|
||||
await sleep(retryAfter * 1000)
|
||||
continue
|
||||
}
|
||||
const err = new Error(`${method} ${path} -> 403: ${text}`)
|
||||
err.status = 403
|
||||
throw err
|
||||
}
|
||||
|
||||
if (res.status >= 500 && attempt < MAX_RETRIES) {
|
||||
console.warn(`${method} ${path} -> ${res.status}, retrying in ${5 * attempt}s`)
|
||||
await sleep(5000 * attempt)
|
||||
continue
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text()
|
||||
const err = new Error(`${method} ${path} -> ${res.status}: ${text}`)
|
||||
err.status = res.status
|
||||
throw err
|
||||
}
|
||||
|
||||
if (res.status === 204) return null
|
||||
return res.json()
|
||||
}
|
||||
throw new Error(`${method} ${path}: exhausted retries`)
|
||||
}
|
||||
|
||||
/** Paginated search/issues. Caps at `maxPages` * 100 results. */
|
||||
export async function searchIssues(query, { maxPages = 5 } = {}) {
|
||||
const items = []
|
||||
for (let page = 1; page <= maxPages; page++) {
|
||||
const data = await api(`/search/issues?q=${encodeURIComponent(query)}&per_page=100&page=${page}`)
|
||||
items.push(...(data.items ?? []))
|
||||
if ((data.items ?? []).length < 100) break
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
export async function listPrFiles(fullRepo, number, { maxPages = 3 } = {}) {
|
||||
const files = []
|
||||
for (let page = 1; page <= maxPages; page++) {
|
||||
const batch = await api(`/repos/${fullRepo}/pulls/${number}/files?per_page=100&page=${page}`)
|
||||
files.push(...batch)
|
||||
if (batch.length < 100) break
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
export function appendOutput(name, value) {
|
||||
const out = process.env.GITHUB_OUTPUT
|
||||
if (out) fs.appendFileSync(out, `${name}=${value}\n`)
|
||||
console.log(`output ${name}=${value}`)
|
||||
}
|
||||
|
||||
export function appendSummary(markdown) {
|
||||
const summary = process.env.GITHUB_STEP_SUMMARY
|
||||
if (summary) fs.appendFileSync(summary, markdown + "\n")
|
||||
}
|
||||
|
||||
/**
|
||||
* Absolute deadline timestamp (ms since epoch) for a wall-clock budget.
|
||||
* Used by triage/edit to stop before the job timeout rather than silently
|
||||
* truncating.
|
||||
*/
|
||||
export function deadline(minutes) {
|
||||
return Date.now() + Number(minutes) * 60 * 1000
|
||||
}
|
||||
|
||||
/** Remaining milliseconds until a deadline; never negative. */
|
||||
export function remainingMs(deadlineMs) {
|
||||
return Math.max(0, Number(deadlineMs) - Date.now())
|
||||
}
|
||||
|
||||
/**
|
||||
* Backoff schedule between kilo-run attempts. Production waits 60s then 300s
|
||||
* (observed outage lasted ~11 min; batch 8 recovered on attempt 2). When
|
||||
* DOCS_SYNC_BACKOFF_MS is set it replaces EVERY wait (`0` disables waiting);
|
||||
* the workflow never sets it — only selftests do.
|
||||
*/
|
||||
export function backoffMsForAttempt(attempt) {
|
||||
// attempt is 1-based; wait happens after attempt N before attempt N+1.
|
||||
const override = process.env.DOCS_SYNC_BACKOFF_MS
|
||||
if (override !== undefined && override !== "") {
|
||||
const n = Number(override)
|
||||
return Number.isFinite(n) && n >= 0 ? n : 0
|
||||
}
|
||||
// After attempt 1 → 60s; after attempt 2 → 300s; nothing after the last.
|
||||
if (attempt === 1) return 60_000
|
||||
if (attempt === 2) return 300_000
|
||||
return 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Blocking sleep used between kilo-run retries. Prefer this over async sleep
|
||||
* so edit/triage stay synchronous around spawnSync.
|
||||
*/
|
||||
export function sleepSync(ms) {
|
||||
const n = Number(ms)
|
||||
if (!Number.isFinite(n) || n <= 0) return
|
||||
const end = Date.now() + n
|
||||
// Atomics.wait is the portable Node sync sleep (no busy loop).
|
||||
const sab = new SharedArrayBuffer(4)
|
||||
const view = new Int32Array(sab)
|
||||
while (Date.now() < end) {
|
||||
const left = end - Date.now()
|
||||
if (left <= 0) break
|
||||
Atomics.wait(view, 0, 0, Math.min(left, 2_147_483_647))
|
||||
}
|
||||
}
|
||||
|
||||
const STDERR_TAIL_LINES = 20
|
||||
const STDERR_TAIL_CHARS = 4_000
|
||||
|
||||
// CSI sequences (colour, cursor moves, erases). kilo renders its TUI to stderr,
|
||||
// so an unstripped tail lands in the rolling PR's pending table as
|
||||
// "^[[0m→ ^[[0mRead packages/..." and the cause is unreadable. Stripped before
|
||||
// the line/char slice so escapes do not eat the budget. The persisted
|
||||
// docs-sync-out/kilo-stderr-*.log stays raw — that is the debugging record.
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const ANSI_CSI = /\u001b\[[0-9;?]*[ -/]*[@-~]/g
|
||||
|
||||
function tailText(text, { lines = STDERR_TAIL_LINES, chars = STDERR_TAIL_CHARS } = {}) {
|
||||
const s = String(text ?? "")
|
||||
.replace(ANSI_CSI, "")
|
||||
.trim()
|
||||
if (!s) return ""
|
||||
const lastLines = s.split("\n").slice(-lines).join("\n")
|
||||
return lastLines.length > chars ? lastLines.slice(-chars) : lastLines
|
||||
}
|
||||
|
||||
/**
|
||||
* Artifact files are raw: GitHub masks secret values in log streams only, and the runner
|
||||
* env holds long-lived secrets (KILO_API_KEY), so exact values of secret-looking env vars
|
||||
* are redacted before stdout/stderr is persisted or printed.
|
||||
* Matching is exact-substring and case-sensitive on values — JSON-escaped, base64'd, or
|
||||
* line-wrapped renderings and values shorter than 8 chars survive (same limitation as
|
||||
* GitHub's own log masking); this is defense-in-depth, not a guarantee the logs are clean.
|
||||
*/
|
||||
export function redactEnvSecrets(text) {
|
||||
let out = String(text ?? "")
|
||||
// Also match CREDENTIAL/PASSWORD/ORG_ID/_PAT (e.g. KILO_ORG_ID, GH_PAT) beyond KEY|TOKEN|SECRET.
|
||||
const nameRe = /KEY|TOKEN|SECRET|CREDENTIAL|PASSWORD|ORG_ID|_PAT$/i
|
||||
const candidates = []
|
||||
for (const [name, value] of Object.entries(process.env)) {
|
||||
if (!nameRe.test(name)) continue
|
||||
if (typeof value !== "string" || value.length < 8) continue
|
||||
candidates.push(value)
|
||||
}
|
||||
// Longer values first so a shorter secret that is a prefix of a longer one cannot leave a remainder.
|
||||
candidates.sort((a, b) => b.length - a.length)
|
||||
for (const value of candidates) {
|
||||
if (!out.includes(value)) continue
|
||||
out = out.split(value).join("***")
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** Max bytes of child stderr persisted to docs-sync-out/ (full buffer, not the console tail). */
|
||||
const STDERR_LOG_MAX_CHARS = 8 * 1024 * 1024
|
||||
|
||||
/**
|
||||
* Run `kilo` via spawnSync so stderr is always recoverable — including when
|
||||
* the child exits 0 after writing a diagnostic (execFileSync cannot return
|
||||
* piped stderr on exit 0; that path lost every diagnostic on run 30122603016).
|
||||
*
|
||||
* streamStdout:true → inherit fd 1 (edit live log); false → capture stdout
|
||||
* (triage parses it). stderr is always buffered.
|
||||
*
|
||||
* Always writes the full captured stderr to
|
||||
* docs-sync-out/kilo-stderr-<sanitized-label>.log (unconditional — success and
|
||||
* failure). The console return value still uses the short tailText.
|
||||
*/
|
||||
export function runKilo({ args, timeoutMs, streamStdout = false, label = "kilo" }) {
|
||||
const result = spawnSync("kilo", args, {
|
||||
encoding: "utf8",
|
||||
maxBuffer: 32 * 1024 * 1024,
|
||||
timeout: timeoutMs,
|
||||
stdio: ["ignore", streamStdout ? "inherit" : "pipe", "pipe"],
|
||||
})
|
||||
|
||||
const timedOut = Boolean(result.error && result.error.code === "ETIMEDOUT")
|
||||
const exitCode =
|
||||
typeof result.status === "number" ? result.status : timedOut ? null : result.status === null ? null : result.status
|
||||
const stderrRaw = String(result.stderr ?? "")
|
||||
const stderrSafe = redactEnvSecrets(stderrRaw)
|
||||
const stderrTail = tailText(stderrSafe)
|
||||
const stdoutSafe = streamStdout ? "" : redactEnvSecrets(String(result.stdout ?? ""))
|
||||
// ok is "process finished without OS-level failure". Callers still treat a
|
||||
// missing summary / unparseable output as failure even when ok is true —
|
||||
// exit 0 is not success for the docs-sync bot.
|
||||
const ok = !result.error && result.status === 0
|
||||
|
||||
// Persist full stderr on every call (not gated on ok/exitCode/summary). Cap is
|
||||
// generous (megabytes) so long batch dumps keep auto-rejecting lines; console
|
||||
// still uses the short tail above.
|
||||
try {
|
||||
fs.mkdirSync("docs-sync-out", { recursive: true })
|
||||
const safe = label.replace(/[^A-Za-z0-9._-]/g, "-")
|
||||
const body = stderrSafe.length > STDERR_LOG_MAX_CHARS ? stderrSafe.slice(-STDERR_LOG_MAX_CHARS) : stderrSafe
|
||||
fs.writeFileSync(`docs-sync-out/kilo-stderr-${safe}.log`, body)
|
||||
} catch (err) {
|
||||
console.warn(`${label}: failed to write kilo-stderr log: ${err.message}`)
|
||||
}
|
||||
|
||||
if (result.error && !timedOut) {
|
||||
console.warn(`${label}: spawn error: ${result.error.message}`)
|
||||
}
|
||||
|
||||
return { ok, stdout: stdoutSafe, stderrTail, exitCode, timedOut }
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
// kilocode_change - new file
|
||||
|
||||
/**
|
||||
* Prepares the rolling docs-sync branch before the edit pass:
|
||||
* - an open auto-docs PR exists -> check out its head branch and merge
|
||||
* origin/main (preserves any human commits on the branch)
|
||||
* - otherwise -> fresh branch from origin/main (bot force-pushes later)
|
||||
*
|
||||
* Outputs: branch, mode (update|fresh|conflict), pr_number (empty when fresh).
|
||||
*/
|
||||
|
||||
import { execFileSync } from "node:child_process"
|
||||
import { pathToFileURL } from "node:url"
|
||||
import { api, appendOutput, repo, searchIssues } from "./lib.mjs"
|
||||
|
||||
export const DEFAULT_BRANCH = "docs/auto-sync"
|
||||
|
||||
const defaultGit = (args) => execFileSync("git", args, { stdio: ["ignore", "pipe", "inherit"] }).toString().trim()
|
||||
|
||||
/**
|
||||
* Merge origin/main into the current branch. On a genuine conflict, abort the
|
||||
* merge, switch to a dated fallback branch from origin/main, and return
|
||||
* mode=conflict so human commits on the rolling branch stay untouched. Any
|
||||
* other merge failure (missing identity, corrupt ref, fetch issues) is
|
||||
* rethrown so the job fails loudly.
|
||||
*/
|
||||
export function mergeOrFallback({ branch, git = defaultGit }) {
|
||||
try {
|
||||
git(["merge", "origin/main", "--no-edit"])
|
||||
return { branch, mode: "update" }
|
||||
} catch (err) {
|
||||
// Conflict ⇔ unmerged index entries (or MERGE_HEAD still present).
|
||||
// Identity failures and similar abort before a merge is started, so
|
||||
// merge --abort would itself fail — those must rethrow.
|
||||
let unmerged = ""
|
||||
try {
|
||||
unmerged = git(["ls-files", "--unmerged"])
|
||||
} catch {
|
||||
// ls-files itself failing is not a conflict signal
|
||||
}
|
||||
let mergeInProgress = false
|
||||
try {
|
||||
git(["rev-parse", "-q", "--verify", "MERGE_HEAD"])
|
||||
mergeInProgress = true
|
||||
} catch {
|
||||
mergeInProgress = false
|
||||
}
|
||||
const isConflict = unmerged.length > 0 || mergeInProgress
|
||||
if (!isConflict) throw err
|
||||
|
||||
console.warn(`merge of origin/main into ${branch} conflicted.`)
|
||||
console.warn(
|
||||
"Leaving the conflicted branch untouched so human commits are preserved; continuing on a fresh dated branch.",
|
||||
)
|
||||
git(["merge", "--abort"])
|
||||
const fallback = `${DEFAULT_BRANCH}-${new Date().toISOString().slice(0, 10)}`
|
||||
try {
|
||||
git(["fetch", "origin", `+refs/heads/${fallback}:refs/remotes/origin/${fallback}`])
|
||||
} catch {
|
||||
console.log(`dated branch ${fallback} does not exist on origin yet; will create it on push`)
|
||||
}
|
||||
git(["checkout", "-B", fallback, "origin/main"])
|
||||
return { branch: fallback, mode: "conflict" }
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const git = defaultGit
|
||||
const prs = await searchIssues(`repo:${repo()} is:pr is:open label:auto-docs sort:created-desc`, { maxPages: 1 })
|
||||
|
||||
let mode = "fresh"
|
||||
let prNumber = ""
|
||||
let branch = DEFAULT_BRANCH
|
||||
|
||||
if (prs.length > 0) {
|
||||
const pr = await api(`/repos/${repo()}/pulls/${prs[0].number}`)
|
||||
branch = pr.head?.ref ?? DEFAULT_BRANCH
|
||||
prNumber = String(pr.number)
|
||||
git(["fetch", "origin", "main", branch])
|
||||
git(["checkout", branch])
|
||||
;({ branch, mode } = mergeOrFallback({ branch, git }))
|
||||
} else {
|
||||
// Keep the remote-tracking ref current so the later --force-with-lease
|
||||
// push (stale branch left over from a merged/closed PR) is safe.
|
||||
try {
|
||||
git(["fetch", "origin", `+refs/heads/${branch}:refs/remotes/origin/${branch}`])
|
||||
} catch {
|
||||
console.log(`branch ${branch} does not exist on origin yet; will create it on push`)
|
||||
}
|
||||
git(["checkout", "-B", branch, "origin/main"])
|
||||
}
|
||||
|
||||
appendOutput("branch", branch)
|
||||
appendOutput("mode", mode)
|
||||
appendOutput("pr_number", prNumber)
|
||||
console.log(`branch ${branch} ready (mode=${mode}, pr=${prNumber || "none"})`)
|
||||
}
|
||||
|
||||
const isMain = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href
|
||||
if (isMain) {
|
||||
main().catch((err) => {
|
||||
console.error(err)
|
||||
process.exit(1)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// kilocode_change - new file
|
||||
|
||||
/**
|
||||
* Line-wise stdin→stdout filter that redacts secret-looking env values.
|
||||
* Used in the docs-sync workflow so kilo stdout piped to edit-log.txt is safe.
|
||||
* Env values contain no newlines, so line-wise processing never splits a value.
|
||||
*/
|
||||
|
||||
import { redactEnvSecrets } from "./lib.mjs"
|
||||
|
||||
let carry = ""
|
||||
|
||||
process.stdin.setEncoding("utf8")
|
||||
process.stdin.on("data", (chunk) => {
|
||||
carry += chunk
|
||||
let idx
|
||||
while ((idx = carry.indexOf("\n")) !== -1) {
|
||||
const line = carry.slice(0, idx + 1)
|
||||
carry = carry.slice(idx + 1)
|
||||
process.stdout.write(redactEnvSecrets(line))
|
||||
}
|
||||
})
|
||||
process.stdin.on("end", () => {
|
||||
if (carry.length > 0) process.stdout.write(redactEnvSecrets(carry))
|
||||
})
|
||||
@@ -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 }
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,20 @@
|
||||
You are the triage pass of an automated documentation pipeline for Kilo Code. Kilo Code is an open-source agentic engineering platform: VS Code extension, JetBrains plugin, CLI, and the kilo.ai cloud platform (teams, KiloClaw, gateway, code reviews).
|
||||
|
||||
The attached `digest.json` file contains PRs recently merged to Kilo-Org/cloud and Kilo-Org/kilocode. Your only job is to decide which of them require changes to the public product documentation at kilo.ai/docs.
|
||||
|
||||
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, 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.
|
||||
- `priority` reflects user impact: high = most users affected, medium = notable subset, low = edge case.
|
||||
|
||||
Respond with a STRICT JSON array and nothing else: no prose, no markdown fences, no comments. Schema:
|
||||
|
||||
[{"pr": 123, "url": "https://github.com/Kilo-Org/kilocode/pull/123", "docs_worthy": true, "reason": "Adds --variant flag to kilo run", "target_sections": ["code-with-ai/platforms/cli"], "priority": "high"}]
|
||||
@@ -0,0 +1,194 @@
|
||||
// kilocode_change - new file
|
||||
|
||||
/**
|
||||
* Runs the LLM triage pass over docs-sync-out/digest.json in chunks.
|
||||
*
|
||||
* A daily window holds ~30-50 PRs; a replay can hold several hundred. A
|
||||
* single triage call over that volume truncates its JSON output, so the
|
||||
* digest is split into chunks of CHUNK_SIZE and each chunk is triaged with
|
||||
* its own `kilo run` call. A chunk that fails, is only partially classified,
|
||||
* or is deferred by the wall-clock budget is marked pending:true (still
|
||||
* docs_worthy:false so filter-worthy excludes it) so the watermark holds
|
||||
* back and the next run re-collects those PRs.
|
||||
*
|
||||
* Env: TRIAGE_MODEL (provider/model), KILO_API_KEY + KILO_ORG_ID (gateway auth, set by
|
||||
* the workflow; the kilo provider reads them natively). Reads the prompt from triage-prompt.md next to this script.
|
||||
* Budget: TRIAGE_BUDGET_MINUTES (default 35). Test hook: DOCS_SYNC_BACKOFF_MS.
|
||||
*/
|
||||
|
||||
import fs from "node:fs"
|
||||
import path from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { parseTriageEntries } from "./extract-json.mjs"
|
||||
import { appendSummary, backoffMsForAttempt, deadline, remainingMs, runKilo, sleepSync } from "./lib.mjs"
|
||||
|
||||
const CHUNK_SIZE = 25
|
||||
const ATTEMPTS = 3
|
||||
const OUT_DIR = "docs-sync-out"
|
||||
const CHUNK_TIMEOUT_MS = 10 * 60 * 1000
|
||||
|
||||
const HERE = path.dirname(fileURLToPath(import.meta.url))
|
||||
const prompt = fs.readFileSync(path.join(HERE, "triage-prompt.md"), "utf8")
|
||||
const model = process.env.TRIAGE_MODEL
|
||||
if (!model) throw new Error("TRIAGE_MODEL is required")
|
||||
|
||||
const TRIAGE_BUDGET_MINUTES = Number(process.env.TRIAGE_BUDGET_MINUTES) || 35
|
||||
|
||||
const digest = JSON.parse(fs.readFileSync(`${OUT_DIR}/digest.json`, "utf8"))
|
||||
|
||||
function formatCause(result) {
|
||||
const bits = []
|
||||
if (result.timedOut) bits.push("timed out")
|
||||
if (result.exitCode !== null && result.exitCode !== undefined) bits.push(`exit ${result.exitCode}`)
|
||||
if (result.stderrTail) bits.push(result.stderrTail.replaceAll("\n", " ").slice(0, 200))
|
||||
return bits.join("; ") || "no diagnostic"
|
||||
}
|
||||
|
||||
function pendingEntry(d, reason) {
|
||||
return {
|
||||
pr: d.number,
|
||||
url: d.url,
|
||||
docs_worthy: false,
|
||||
pending: true,
|
||||
reason,
|
||||
target_sections: [],
|
||||
priority: "medium",
|
||||
}
|
||||
}
|
||||
|
||||
function triageChunk(chunk, index, budgetDeadline) {
|
||||
const chunkFile = `${OUT_DIR}/triage-chunk-${index}.json`
|
||||
fs.writeFileSync(chunkFile, JSON.stringify(chunk, null, 2))
|
||||
|
||||
let lastCause = "triage failed to classify this PR"
|
||||
for (let attempt = 1; attempt <= ATTEMPTS; attempt++) {
|
||||
const left = remainingMs(budgetDeadline)
|
||||
if (left < CHUNK_TIMEOUT_MS) {
|
||||
lastCause = `triage budget exhausted before chunk ${index} attempt ${attempt}`
|
||||
console.warn(
|
||||
`chunk ${index}: stopping retries — remaining budget cannot fit another ${CHUNK_TIMEOUT_MS / 60000}m attempt`,
|
||||
)
|
||||
break
|
||||
}
|
||||
|
||||
// Headless `kilo run` auto-rejects every permission ask; without --auto the
|
||||
// agent cannot run shell commands. SECURITY: --auto grants unrestricted bash
|
||||
// to an agent steered by external PR content. Hardening deferred: a scoped
|
||||
// permission.bash map via KILO_CONFIG_CONTENT should replace --auto once the
|
||||
// required shell patterns are stable (see PR #12605 review thread).
|
||||
const result = runKilo({
|
||||
args: ["run", "--auto", prompt, "-m", model, "--dir", process.cwd(), "-f", chunkFile],
|
||||
timeoutMs: Math.min(CHUNK_TIMEOUT_MS, left),
|
||||
streamStdout: false,
|
||||
label: `triage chunk ${index} attempt ${attempt}`,
|
||||
})
|
||||
|
||||
const raw = result.stdout
|
||||
if (raw) fs.writeFileSync(`${OUT_DIR}/triage-raw-${index}.txt`, raw)
|
||||
|
||||
const entries = raw ? parseTriageEntries(raw) : null
|
||||
if (entries) {
|
||||
// An entry for a PR outside this chunk must not win the shared dedupe
|
||||
// against the chunk that actually owns it — drop foreign entries.
|
||||
const allowed = new Set(chunk.map((d) => d.url))
|
||||
const owned = entries.filter((e) => allowed.has(e.url))
|
||||
if (owned.length !== entries.length) {
|
||||
console.warn(`chunk ${index}: dropped ${entries.length - owned.length} entries for PRs outside the chunk`)
|
||||
}
|
||||
if (owned.length > 0) return owned
|
||||
}
|
||||
|
||||
// Exit 0 is not success: unparseable output is a failure logged WITH
|
||||
// the captured stderrTail and exit code on every attempt.
|
||||
const cause = formatCause(result)
|
||||
lastCause = `triage chunk ${index}: ${cause}`
|
||||
console.warn(
|
||||
`chunk ${index} attempt ${attempt}: no valid JSON in output` +
|
||||
` (exit ${result.exitCode}${result.timedOut ? ", timed out" : ""})` +
|
||||
(result.stderrTail ? `\nstderr tail:\n${result.stderrTail}` : "\nstderr tail: (empty)"),
|
||||
)
|
||||
|
||||
if (attempt < ATTEMPTS) {
|
||||
const wait = backoffMsForAttempt(attempt)
|
||||
const afterWait = remainingMs(budgetDeadline) - wait
|
||||
if (wait > 0 && afterWait >= CHUNK_TIMEOUT_MS) {
|
||||
console.warn(`chunk ${index}: backing off ${wait / 1000}s before attempt ${attempt + 1}`)
|
||||
sleepSync(wait)
|
||||
} else if (wait > 0) {
|
||||
console.warn(
|
||||
`chunk ${index}: skipping backoff — remaining budget cannot fit attempt ${attempt + 1} after wait`,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.warn(
|
||||
`::warning::chunk ${index} failed triage after up to ${ATTEMPTS} attempts; marking ${chunk.length} PRs pending`,
|
||||
)
|
||||
return chunk.map((d) => pendingEntry(d, lastCause.includes("triage failed") ? lastCause : `triage failed to classify this PR (${lastCause})`))
|
||||
}
|
||||
|
||||
const chunks = []
|
||||
for (let i = 0; i < digest.length; i += CHUNK_SIZE) {
|
||||
chunks.push(digest.slice(i, i + CHUNK_SIZE))
|
||||
}
|
||||
console.log(`triaging ${digest.length} PRs in ${chunks.length} chunks of up to ${CHUNK_SIZE}`)
|
||||
|
||||
const budgetDeadline = deadline(TRIAGE_BUDGET_MINUTES)
|
||||
const merged = []
|
||||
const seen = new Set()
|
||||
|
||||
for (let i = 0; i < chunks.length; i++) {
|
||||
const left = remainingMs(budgetDeadline)
|
||||
if (left < CHUNK_TIMEOUT_MS) {
|
||||
const deferredPrs = chunks.slice(i).reduce((n, c) => n + c.length, 0)
|
||||
console.warn(
|
||||
`stopping triage before chunk ${i}: remaining budget (${Math.ceil(left / 1000)}s) cannot fit a ${CHUNK_TIMEOUT_MS / 60000}m chunk; deferring ${deferredPrs} PRs`,
|
||||
)
|
||||
const cause = `triage budget exhausted before chunk ${i} (${Math.ceil(left / 1000)}s left)`
|
||||
for (let j = i; j < chunks.length; j++) {
|
||||
for (const d of chunks[j]) {
|
||||
if (seen.has(d.url)) continue
|
||||
seen.add(d.url)
|
||||
merged.push(pendingEntry(d, cause))
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
for (const e of triageChunk(chunks[i], i, budgetDeadline)) {
|
||||
if (seen.has(e.url)) continue
|
||||
seen.add(e.url)
|
||||
merged.push(e)
|
||||
}
|
||||
}
|
||||
|
||||
// Coverage: every digest PR gets a triage entry. Partial-chunk backfill and
|
||||
// any other missing URL are pending:true — not a genuine "not worthy" verdict.
|
||||
for (const d of digest) {
|
||||
if (seen.has(d.url)) continue
|
||||
merged.push(pendingEntry(d, "not classified by triage"))
|
||||
}
|
||||
|
||||
fs.writeFileSync(`${OUT_DIR}/triage.json`, JSON.stringify(merged, null, 2))
|
||||
const worthy = merged.filter((e) => e.docs_worthy).length
|
||||
const pending = merged.filter((e) => e.pending === true)
|
||||
console.log(`triage complete: ${merged.length} entries, ${worthy} docs-worthy, ${pending.length} pending`)
|
||||
|
||||
// Upsert is gated off when worthy == 0, so triage emits its own Step Summary
|
||||
// listing every PR it marked pending:true and why.
|
||||
if (pending.length > 0) {
|
||||
const lines = pending.map((e) => `- [${e.url}] ${e.reason}`)
|
||||
appendSummary(
|
||||
`### docs-sync: triage pending (will retry)\n\n${pending.length} PR(s) were not classified and will be re-collected on the next run:\n\n${lines.join("\n")}`,
|
||||
)
|
||||
}
|
||||
|
||||
// Replay warning (S2j): warn IFF since-override AND something pending AND
|
||||
// docs-worthy count is 0 (Upsert is gated off, so noDiffReport never runs).
|
||||
const sinceOverride = process.env.SINCE_OVERRIDE === "true"
|
||||
if (sinceOverride && pending.length > 0 && worthy === 0) {
|
||||
console.warn(
|
||||
"::warning::docs-sync since-override replay left uncovered PRs and wrote no PR body (worthy=0); re-run the override — the watermark was not held back in the body",
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,471 @@
|
||||
// kilocode_change - new file
|
||||
|
||||
/**
|
||||
* Commits the agent's packages/kilo-docs changes, pushes the rolling branch,
|
||||
* and creates or updates the rolling auto-docs PR.
|
||||
*
|
||||
* No-op when the agent produced no docs changes. PRs become drafts when the
|
||||
* diff exceeds the file cap or verification failed. The PR body carries
|
||||
* marker-delimited sections so later runs can append rows, plus a
|
||||
* machine-readable processed-through watermark.
|
||||
*
|
||||
* Watermark invariant: processed-through never moves past a PR that has no
|
||||
* terminal outcome. Terminal := action !== "pending" (a deliberate agent
|
||||
* "skipped" IS terminal). Uncovered PRs hold the marker at earliest
|
||||
* merged_at − 1 ms so collect's merged:>=since re-collects them next run.
|
||||
* Three review rounds found four independent defects in a queue-based
|
||||
* alternative (unreachable gate, empty-PR creation, cap-overflow loss,
|
||||
* draft-state corruption); a held-back watermark has none of those modes.
|
||||
*/
|
||||
|
||||
import { execFileSync } from "node:child_process"
|
||||
import fs from "node:fs"
|
||||
import { pathToFileURL } from "node:url"
|
||||
|
||||
const BRANCH = process.env.BRANCH || "docs/auto-sync"
|
||||
const FILE_CAP = 15
|
||||
const ROW_CAP = 150
|
||||
const PENDING_DISPLAY_CAP = 60
|
||||
const SUMMARY_FILE = ".docs-sync-summary.json"
|
||||
const DOCS_PATH = "packages/kilo-docs"
|
||||
|
||||
const git = (args) => execFileSync("git", args, { stdio: ["ignore", "pipe", "inherit"] }).toString().trim()
|
||||
|
||||
// Agent-generated strings land in the PR body next to machine-read markers.
|
||||
// Strip HTML-comment sequences so a crafted/adversarial value cannot forge
|
||||
// section boundaries or the processed-through watermark.
|
||||
function clean(value) {
|
||||
return String(value ?? "").replaceAll("<!--", "").replaceAll("-->", "")
|
||||
}
|
||||
|
||||
function shortRef(url) {
|
||||
return clean(url).replace("https://github.com/", "").replace("/pull/", "#")
|
||||
}
|
||||
|
||||
function changeRow(e) {
|
||||
return `| ${clean(e.action).replaceAll("|", "\\|")} | [${shortRef(e.url)}](${clean(e.url)}) |`
|
||||
}
|
||||
|
||||
function skippedRow(e) {
|
||||
const reason = clean(e.reason).replaceAll("|", "\\|").replaceAll("\n", " ")
|
||||
return `| [${shortRef(e.url)}](${clean(e.url)}) | ${reason} |`
|
||||
}
|
||||
|
||||
function pendingRow(e) {
|
||||
const reason = clean(e.reason ?? e.cause ?? "").replaceAll("|", "\\|").replaceAll("\n", " ")
|
||||
return `| [${shortRef(e.url)}](${clean(e.url)}) | ${reason} |`
|
||||
}
|
||||
|
||||
export function extractSectionRows(body, name) {
|
||||
const m = String(body ?? "").match(
|
||||
new RegExp(`<!--\\s*docs-sync:${name}:start\\s*-->([\\s\\S]*?)<!--\\s*docs-sync:${name}:end\\s*-->`),
|
||||
)
|
||||
if (!m) return []
|
||||
return m[1]
|
||||
.split("\n")
|
||||
.map((l) => l.trim())
|
||||
.filter(
|
||||
(l) =>
|
||||
l.startsWith("|") &&
|
||||
!l.startsWith("| ---") &&
|
||||
!/^\|\s*Docs change/.test(l) &&
|
||||
!/^\|\s*PR\s*\|/.test(l) &&
|
||||
!/^\|\s*Why\s*\|/.test(l),
|
||||
)
|
||||
}
|
||||
|
||||
function section(name, header, rows) {
|
||||
const body = rows.length > 0 ? [header, "| --- | --- |", ...rows].join("\n") : "_None._"
|
||||
return `<!-- docs-sync:${name}:start -->\n${body}\n<!-- docs-sync:${name}:end -->`
|
||||
}
|
||||
|
||||
export function renderBody({ date, since, through, changesRows, pendingRows, skippedRows, verified, draftReasons, note }) {
|
||||
const pendingDisplay =
|
||||
pendingRows.length > PENDING_DISPLAY_CAP
|
||||
? [...pendingRows.slice(0, PENDING_DISPLAY_CAP), `| +${pendingRows.length - PENDING_DISPLAY_CAP} more | |`]
|
||||
: pendingRows
|
||||
|
||||
return `## Automated docs sync — ${date}
|
||||
|
||||
This PR keeps kilo.ai/docs in sync with features merged to [Kilo-Org/cloud](https://github.com/Kilo-Org/cloud) and [Kilo-Org/kilocode](https://github.com/Kilo-Org/kilocode). Every change below links to the merged PR it documents.
|
||||
|
||||
- Window: \`${since}\` → \`${through}\`
|
||||
- Verification (docs build + tests): **${verified ? "passing" : "FAILING — needs a human look"}**
|
||||
${note ? `- ${note}\n` : ""}${draftReasons.length > 0 ? `- Draft because: ${draftReasons.join("; ")}\n` : ""}
|
||||
### Changes
|
||||
|
||||
${section("changes", "| Docs change | Source |", changesRows)}
|
||||
|
||||
### Pending — will retry
|
||||
|
||||
${section("pending", "| PR | Why |", pendingDisplay)}
|
||||
|
||||
### Considered, no docs change needed
|
||||
|
||||
${section("skipped", "| PR | Reason |", skippedRows)}
|
||||
|
||||
---
|
||||
|
||||
(bot) Generated by the docs-sync workflow. Humans review and merge; while this PR stays open, the next daily run appends new changes here. Branch: \`${BRANCH}\`.
|
||||
<!-- docs-sync: processed-through ${through} -->
|
||||
`
|
||||
}
|
||||
|
||||
function mergeRows(oldRows, newRows) {
|
||||
const seen = new Set()
|
||||
const out = []
|
||||
for (const row of [...oldRows, ...newRows]) {
|
||||
if (seen.has(row)) continue
|
||||
seen.add(row)
|
||||
out.push(row)
|
||||
}
|
||||
return out.slice(-ROW_CAP)
|
||||
}
|
||||
|
||||
function readJson(path, fallback) {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(path, "utf8"))
|
||||
} catch {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Uncovered = (worthy URLs with no summary row) ∪ (summary action "pending")
|
||||
* ∪ (triage entries with pending: true). A worthy PR is covered iff it has a
|
||||
* summary row whose action !== "pending" and carries no triage pending flag.
|
||||
*/
|
||||
export function computeUncovered({ worthy, summary, triage }) {
|
||||
const worthyList = Array.isArray(worthy) ? worthy : []
|
||||
const summaryList = Array.isArray(summary) ? summary : []
|
||||
const triageList = Array.isArray(triage) ? triage : []
|
||||
|
||||
const summaryByUrl = new Map()
|
||||
for (const e of summaryList) {
|
||||
if (e?.url) summaryByUrl.set(e.url, e)
|
||||
}
|
||||
|
||||
const triagePendingByUrl = new Map()
|
||||
for (const e of triageList) {
|
||||
if (e?.url && e.pending === true) triagePendingByUrl.set(e.url, e)
|
||||
}
|
||||
|
||||
/** @type {Map<string, { url: string, pr?: number, reason: string }>} */
|
||||
const out = new Map()
|
||||
|
||||
for (const w of worthyList) {
|
||||
const url = w?.url
|
||||
if (!url) continue
|
||||
const row = summaryByUrl.get(url)
|
||||
if (!row) {
|
||||
out.set(url, {
|
||||
url,
|
||||
pr: w.number ?? w.pr,
|
||||
reason: "no edit summary row (edit pass did not cover this PR)",
|
||||
})
|
||||
continue
|
||||
}
|
||||
if (row.action === "pending") {
|
||||
out.set(url, {
|
||||
url,
|
||||
pr: row.pr ?? w.number ?? w.pr,
|
||||
reason: row.reason || "edit pass pending",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Summary pending rows for URLs not in worthy (defensive).
|
||||
for (const row of summaryList) {
|
||||
if (row?.action === "pending" && row.url && !out.has(row.url)) {
|
||||
out.set(row.url, {
|
||||
url: row.url,
|
||||
pr: row.pr,
|
||||
reason: row.reason || "edit pass pending",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
for (const [url, e] of triagePendingByUrl) {
|
||||
if (out.has(url)) continue
|
||||
out.set(url, {
|
||||
url,
|
||||
pr: e.pr,
|
||||
reason: e.reason || "triage pending",
|
||||
})
|
||||
}
|
||||
|
||||
return [...out.values()]
|
||||
}
|
||||
|
||||
/**
|
||||
* processed-through = now when uncovered is empty; otherwise earliest
|
||||
* merged_at among uncovered PRs minus 1 ms (from digest-full.json).
|
||||
* When uncovered is non-empty but no merged_at resolves, hold at
|
||||
* `fallback` (the run's window start / SINCE): every uncovered PR was
|
||||
* collected via merged:>=since, so holding there re-collects all of them.
|
||||
* Never advance past unresolved uncovered PRs (Defect-B permanent-loss).
|
||||
*/
|
||||
export function computeProcessedThrough({ uncovered, digest, now, fallback }) {
|
||||
const nowIso = typeof now === "string" ? now : new Date(now).toISOString()
|
||||
if (!uncovered || uncovered.length === 0) return nowIso
|
||||
|
||||
const digestList = Array.isArray(digest) ? digest : []
|
||||
const byUrl = new Map(digestList.filter((d) => d?.url).map((d) => [d.url, d]))
|
||||
|
||||
let earliest = null
|
||||
for (const u of uncovered) {
|
||||
const d = byUrl.get(u.url)
|
||||
const mergedAt = d?.merged_at
|
||||
if (!mergedAt) continue
|
||||
const t = Date.parse(mergedAt)
|
||||
if (!Number.isFinite(t)) continue
|
||||
if (earliest === null || t < earliest) earliest = t
|
||||
}
|
||||
|
||||
if (earliest === null) {
|
||||
// digest-full missing/corrupt while uncovered is non-empty: hold at
|
||||
// window start so collect's merged:>=since re-collects every PR.
|
||||
// Never use now−1ms — that strands uncovered PRs permanently.
|
||||
const fallbackMs = fallback == null ? NaN : Date.parse(fallback)
|
||||
if (!Number.isFinite(fallbackMs)) {
|
||||
throw new Error(
|
||||
`docs-sync: cannot resolve merged_at for ${uncovered.length} uncovered PR(s) and fallback/SINCE is missing or unparseable; refusing to advance processed-through`,
|
||||
)
|
||||
}
|
||||
const fallbackIso = new Date(fallbackMs).toISOString()
|
||||
console.warn(
|
||||
`::warning::docs-sync: merge times for ${uncovered.length} uncovered PR(s) could not be resolved; holding watermark at window start ${fallbackIso}`,
|
||||
)
|
||||
return fallbackIso
|
||||
}
|
||||
|
||||
return new Date(earliest - 1).toISOString()
|
||||
}
|
||||
|
||||
/**
|
||||
* Route summary + triage into the three body sections.
|
||||
* changesRows = action neither skipped nor pending
|
||||
* pendingRows = uncovered from computeUncovered
|
||||
* skippedRows = action === "skipped" ∪ triage docs_worthy false && !pending
|
||||
*/
|
||||
export function routeRows({ summary, triage, uncovered }) {
|
||||
const summaryList = Array.isArray(summary) ? summary : []
|
||||
const triageList = Array.isArray(triage) ? triage : []
|
||||
const uncoveredList = Array.isArray(uncovered) ? uncovered : []
|
||||
|
||||
const changesEntries = summaryList.filter((e) => e.action !== "skipped" && e.action !== "pending")
|
||||
const skippedEntries = [
|
||||
...triageList.filter((e) => e.docs_worthy === false && e.pending !== true),
|
||||
...summaryList.filter((e) => e.action === "skipped"),
|
||||
]
|
||||
|
||||
return {
|
||||
changesRows: changesEntries.map(changeRow),
|
||||
pendingRows: uncoveredList.map(pendingRow),
|
||||
skippedRows: skippedEntries.map(skippedRow),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop pre-existing Considered rows whose reason contains any of the three
|
||||
* legacy failure literals (substring match — live rows carry longer strings).
|
||||
* Genuine no-doc-needed rows are untouched.
|
||||
*/
|
||||
export function dropLegacySkipped(rows) {
|
||||
const list = Array.isArray(rows) ? rows : []
|
||||
const needles = ["edit pass failed or timed out", "triage failed to classify", "not classified by triage"]
|
||||
return list.filter((row) => {
|
||||
const s = String(row ?? "")
|
||||
return !needles.some((n) => s.includes(n))
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* No-diff early-return report. Returns summary markdown and an optional
|
||||
* replay warning. Warns IFF sinceOverride && uncovered non-empty (no commit
|
||||
* happened — that is the caller's situation).
|
||||
*/
|
||||
export function noDiffReport({ uncovered, sinceOverride }) {
|
||||
const list = Array.isArray(uncovered) ? uncovered : []
|
||||
const lines =
|
||||
list.length === 0
|
||||
? ["The agent found nothing worth documenting in this window."]
|
||||
: [
|
||||
`No packages/kilo-docs diff was produced, but ${list.length} PR(s) remain uncovered and will be re-collected on the next scheduled run:`,
|
||||
"",
|
||||
...list.map((u) => `- [${u.url}] ${u.reason || "uncovered"}`),
|
||||
]
|
||||
|
||||
const summary = `### docs-sync: no docs changes\n\n${lines.join("\n")}`
|
||||
|
||||
let warning = null
|
||||
if (sinceOverride && list.length > 0) {
|
||||
warning =
|
||||
"docs-sync since-override replay left uncovered PRs and wrote no PR body (no docs commit); re-run the override — the watermark was not held back in the body"
|
||||
}
|
||||
|
||||
return { summary, warning }
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const { api, appendOutput, appendSummary, repo } = await import("./lib.mjs")
|
||||
|
||||
const now = process.env.PROCESSED_THROUGH ?? new Date().toISOString()
|
||||
const since = process.env.SINCE ?? "unknown"
|
||||
const sinceOverride = process.env.SINCE_OVERRIDE === "true"
|
||||
const mode = ["update", "conflict"].includes(process.env.PREP_MODE) ? process.env.PREP_MODE : "fresh"
|
||||
const existingPr = process.env.PR_NUMBER || ""
|
||||
const verified = process.env.VERIFIED === "true"
|
||||
const date = now.slice(0, 10)
|
||||
|
||||
// The agent's run summary is consumed here and never committed.
|
||||
const agentSummary = readJson(SUMMARY_FILE, [])
|
||||
fs.rmSync(SUMMARY_FILE, { force: true })
|
||||
const triage = readJson("docs-sync-out/triage.json", [])
|
||||
const worthy = readJson("docs-sync-out/worthy.json", [])
|
||||
const digest = readJson("docs-sync-out/digest-full.json", [])
|
||||
|
||||
// Order matters: compute uncovered BEFORE the no-diff early return so
|
||||
// noDiffReport can name every held-back PR.
|
||||
const uncovered = computeUncovered({ worthy, summary: agentSummary, triage })
|
||||
|
||||
if (git(["status", "--porcelain", "--", DOCS_PATH]) === "") {
|
||||
console.log("no packages/kilo-docs changes produced; nothing to commit")
|
||||
const { summary, warning } = noDiffReport({ uncovered, sinceOverride })
|
||||
appendSummary(summary)
|
||||
if (warning) console.warn(`::warning::${warning}`)
|
||||
return
|
||||
}
|
||||
|
||||
// Git identity is configured once in docs-sync.yml (Configure git identity)
|
||||
// before any commit-creating step, including prepare-branch's merge.
|
||||
git(["add", DOCS_PATH])
|
||||
git(["commit", "-m", `docs: sync with merged PRs (${date})`])
|
||||
|
||||
// Watermark: now when fully covered; else earliest uncovered merged_at − 1ms.
|
||||
// Pass SINCE as fallback so missing digest-full cannot strand uncovered PRs.
|
||||
const through = computeProcessedThrough({ uncovered, digest, now, fallback: since })
|
||||
|
||||
// The draft cap bounds the cumulative PR diff, not just this run's commit.
|
||||
const changedFiles = git(["diff", "--name-only", "origin/main...HEAD", "--", DOCS_PATH])
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
const draftReasons = []
|
||||
if (changedFiles.length > FILE_CAP) draftReasons.push(`diff exceeds ${FILE_CAP} files (${changedFiles.length})`)
|
||||
if (!verified) draftReasons.push("docs build/tests not passing")
|
||||
// Content gate: legitimate bot edits are docs pages and nav files. Anything
|
||||
// else in the docs package (build config, components, tests) executes
|
||||
// during the verify build, so force human review before merge.
|
||||
const nonContent = changedFiles.filter(
|
||||
(f) => !f.startsWith("packages/kilo-docs/pages/") && !f.startsWith("packages/kilo-docs/lib/nav/"),
|
||||
)
|
||||
if (nonContent.length > 0) {
|
||||
// File paths are agent-chosen; sanitize before they land in the PR body.
|
||||
const listed = nonContent
|
||||
.slice(0, 5)
|
||||
.map((f) => clean(f).replaceAll("|", "\\|"))
|
||||
.join(", ")
|
||||
draftReasons.push(`touches non-content files outside pages/ and lib/nav/: ${listed}`)
|
||||
}
|
||||
const draft = draftReasons.length > 0
|
||||
|
||||
git(mode === "update" ? ["push", "origin", `HEAD:${BRANCH}`] : ["push", "--force-with-lease", "origin", `HEAD:${BRANCH}`])
|
||||
|
||||
const { changesRows: changesNew, pendingRows: pendingNew, skippedRows: skippedNew } = routeRows({
|
||||
summary: agentSummary,
|
||||
triage,
|
||||
uncovered,
|
||||
})
|
||||
|
||||
let oldChanges = []
|
||||
let oldSkipped = []
|
||||
let oldPending = []
|
||||
if (mode === "update" && existingPr) {
|
||||
const pr = await api(`/repos/${repo()}/pulls/${existingPr}`)
|
||||
oldChanges = extractSectionRows(pr.body, "changes")
|
||||
oldSkipped = dropLegacySkipped(extractSectionRows(pr.body, "skipped"))
|
||||
oldPending = extractSectionRows(pr.body, "pending")
|
||||
}
|
||||
|
||||
// Pending is replaced each run (informational only); do not merge legacy
|
||||
// pending rows — uncovered is recomputed fresh. oldPending is read only so
|
||||
// extractSectionRows stays exercised; discarded deliberately.
|
||||
void oldPending
|
||||
|
||||
const body = renderBody({
|
||||
date,
|
||||
since,
|
||||
through,
|
||||
changesRows: mergeRows(oldChanges, changesNew),
|
||||
pendingRows: pendingNew,
|
||||
skippedRows: mergeRows(oldSkipped, skippedNew),
|
||||
verified,
|
||||
draftReasons,
|
||||
note:
|
||||
mode === "conflict" && existingPr
|
||||
? `Continues from #${existingPr}, whose branch conflicted with \`main\` (its commits are preserved there).`
|
||||
: "",
|
||||
})
|
||||
|
||||
try {
|
||||
await api(`/repos/${repo()}/labels`, {
|
||||
method: "POST",
|
||||
body: { name: "auto-docs", color: "1d76db", description: "Automated docs-sync PRs" },
|
||||
})
|
||||
} catch (err) {
|
||||
if (err.status !== 422) throw err // 422 = label already exists
|
||||
}
|
||||
|
||||
let prNumber
|
||||
let prUrl
|
||||
if (mode === "update" && existingPr) {
|
||||
const pr = await api(`/repos/${repo()}/pulls/${existingPr}`, {
|
||||
method: "PATCH",
|
||||
body: { title: `docs: auto-sync with merged PRs (through ${date})`, body },
|
||||
})
|
||||
prNumber = pr.number
|
||||
prUrl = pr.html_url
|
||||
await api(`/repos/${repo()}/issues/${prNumber}/comments`, {
|
||||
method: "POST",
|
||||
body: {
|
||||
body: `(bot) Appended changes processed through \`${through}\`. Verification: **${verified ? "passing" : "failing"}**.${draft ? ` Draft because: ${draftReasons.join("; ")}.` : ""}`,
|
||||
},
|
||||
})
|
||||
} else {
|
||||
const pr = await api(`/repos/${repo()}/pulls`, {
|
||||
method: "POST",
|
||||
body: {
|
||||
title: `docs: auto-sync with merged PRs (through ${date})`,
|
||||
head: BRANCH,
|
||||
base: "main",
|
||||
body,
|
||||
draft,
|
||||
},
|
||||
})
|
||||
prNumber = pr.number
|
||||
prUrl = pr.html_url
|
||||
await api(`/repos/${repo()}/issues/${prNumber}/labels`, { method: "POST", body: { labels: ["auto-docs"] } })
|
||||
if (mode === "conflict" && existingPr) {
|
||||
await api(`/repos/${repo()}/issues/${existingPr}/comments`, {
|
||||
method: "POST",
|
||||
body: {
|
||||
body: `(bot) This branch conflicted with \`main\`, so the sync continues in ${prUrl}. Commits on this branch are preserved — please close this PR after the new one is reviewed.`,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
appendOutput("pr_url", prUrl)
|
||||
appendSummary(
|
||||
`### docs-sync PR\n\n- ${prUrl}\n- changed files: ${changedFiles.length}\n- draft: ${draft}\n- uncovered: ${uncovered.length}\n- processed-through: ${through}\n`,
|
||||
)
|
||||
console.log(`PR ${prNumber}: ${prUrl} (draft=${draft}, files=${changedFiles.length}, uncovered=${uncovered.length}, through=${through})`)
|
||||
}
|
||||
|
||||
const isMain = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href
|
||||
if (isMain) {
|
||||
main().catch((err) => {
|
||||
console.error(err)
|
||||
process.exit(1)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
// kilocode_change - new file
|
||||
|
||||
/**
|
||||
* Resolves the docs-sync watermark: the timestamp of the newest source PR the
|
||||
* bot has already processed. Derived from the bot's own PRs (marker in the PR
|
||||
* body), so there is no external state to keep consistent.
|
||||
*
|
||||
* Priority: workflow_dispatch input `since` > latest open bot PR marker >
|
||||
* last merged bot PR marker > 72h ago. Hard cap: never look back more than
|
||||
* 14 days — unless the human explicitly requested a window via INPUT_SINCE.
|
||||
*/
|
||||
|
||||
import { pathToFileURL } from "node:url"
|
||||
import { appendOutput, appendSummary, repo, searchIssues } from "./lib.mjs"
|
||||
|
||||
const FALLBACK_HOURS = 72
|
||||
const CAP_DAYS = 14
|
||||
const MARKER = /<!--\s*docs-sync:\s*processed-through\s+(\S+?)\s*-->/
|
||||
|
||||
function extractMarker(body) {
|
||||
const m = (body ?? "").match(MARKER)
|
||||
if (!m) return null
|
||||
const d = new Date(m[1])
|
||||
return Number.isNaN(d.getTime()) ? null : d
|
||||
}
|
||||
|
||||
async function findWatermark() {
|
||||
const r = repo()
|
||||
for (const state of ["open", "merged"]) {
|
||||
const query = `repo:${r} is:pr label:auto-docs sort:created-desc ${state === "open" ? "is:open" : "is:merged"}`
|
||||
const prs = await searchIssues(query, { maxPages: 1 })
|
||||
for (const pr of prs) {
|
||||
// Only trust markers on PRs authored by the bot itself: bodies are
|
||||
// editable and the label can be applied by anyone with triage access.
|
||||
if (pr.user?.login !== "github-actions[bot]") continue
|
||||
const marker = extractMarker(pr.body)
|
||||
if (marker) {
|
||||
console.log(`watermark from ${state} PR #${pr.number}: ${marker.toISOString()}`)
|
||||
return marker
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the 14-day lookback cap. When `explicit` is true (dispatch override),
|
||||
* the cap is skipped so a human-requested recovery window is not silently
|
||||
* shortened. Returns `{ since, clamped }`.
|
||||
*/
|
||||
export function applyCap(since, now, { explicit = false } = {}) {
|
||||
if (explicit) {
|
||||
console.log(`14-day cap skipped: INPUT_SINCE was set explicitly (${since.toISOString()})`)
|
||||
return { since, clamped: false }
|
||||
}
|
||||
const cap = new Date(now.getTime() - CAP_DAYS * 24 * 3600 * 1000)
|
||||
if (since < cap) {
|
||||
const from = since.toISOString()
|
||||
const to = cap.toISOString()
|
||||
console.warn(
|
||||
`::warning::docs-sync watermark clamped from ${from} to ${to} (${CAP_DAYS}-day cap). ` +
|
||||
`Anything still uncovered before ${to} is abandoned and needs a human.`,
|
||||
)
|
||||
return { since: cap, clamped: true }
|
||||
}
|
||||
return { since, clamped: false }
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const now = new Date()
|
||||
let since
|
||||
let explicit = false
|
||||
|
||||
const input = (process.env.INPUT_SINCE ?? "").trim()
|
||||
if (input) {
|
||||
since = new Date(input)
|
||||
if (Number.isNaN(since.getTime())) {
|
||||
throw new Error(`Invalid INPUT_SINCE: ${input}`)
|
||||
}
|
||||
explicit = true
|
||||
console.log(`watermark from dispatch input: ${since.toISOString()}`)
|
||||
} else {
|
||||
since = (await findWatermark()) ?? new Date(now.getTime() - FALLBACK_HOURS * 3600 * 1000)
|
||||
}
|
||||
|
||||
// A forged, edited, or malformed marker in the future would silently match
|
||||
// nothing in the merged:>= search; clamp it loudly.
|
||||
if (since > now) {
|
||||
console.warn(`watermark ${since.toISOString()} is in the future, clamping to now`)
|
||||
since = now
|
||||
}
|
||||
|
||||
;({ since } = applyCap(since, now, { explicit }))
|
||||
|
||||
appendOutput("since", since.toISOString())
|
||||
appendOutput("now", now.toISOString())
|
||||
appendOutput("since_override", explicit ? "true" : "false")
|
||||
appendSummary(`### docs-sync watermark\n\n- since: \`${since.toISOString()}\`\n- now: \`${now.toISOString()}\`\n`)
|
||||
}
|
||||
|
||||
const isMain = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href
|
||||
if (isMain) {
|
||||
main().catch((err) => {
|
||||
console.error(err)
|
||||
process.exit(1)
|
||||
})
|
||||
}
|
||||
@@ -26,15 +26,18 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
call-valtown:
|
||||
# kilocode_change start
|
||||
if: >
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
(
|
||||
github.repository == 'Kilo-Org/kilocode' &&
|
||||
github.event.pull_request.merged == true &&
|
||||
(
|
||||
startsWith(github.event.pull_request.title, 'feat:') ||
|
||||
startsWith(github.event.pull_request.title, 'feat(')
|
||||
)
|
||||
)
|
||||
# kilocode_change end
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
|
||||
@@ -18,7 +18,7 @@ on:
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: blacksmith-2vcpu-ubuntu-2404
|
||||
runs-on: blacksmith-2vcpu-ubuntu-2404 # kilocode_change
|
||||
outputs:
|
||||
is-member: ${{ steps.check.outputs['is-member'] }}
|
||||
steps:
|
||||
|
||||
@@ -19,6 +19,7 @@ on:
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze (java-kotlin)
|
||||
if: github.event_name == 'workflow_dispatch' || github.repository == 'Kilo-Org/kilocode'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
security-events: write
|
||||
|
||||
@@ -24,6 +24,7 @@ on:
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze (${{ matrix.language }})
|
||||
if: github.event_name == 'workflow_dispatch' || github.repository == 'Kilo-Org/kilocode'
|
||||
# Runner size impacts CodeQL analysis time. To learn more, please see:
|
||||
# - https://gh.io/recommended-hardware-resources-for-running-codeql
|
||||
# - https://gh.io/supported-runners-and-hardware-resources
|
||||
|
||||
@@ -16,6 +16,7 @@ permissions:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
if: github.event_name == 'workflow_dispatch' || github.repository == 'Kilo-Org/kilocode' # kilocode_change
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
steps:
|
||||
# kilocode_change start
|
||||
|
||||
@@ -118,7 +118,7 @@ jobs:
|
||||
Remember: post at most ONE comment combining all findings. If everything is fine, post nothing."
|
||||
|
||||
recheck-compliance:
|
||||
if: github.event.action == 'edited' && contains(github.event.issue.labels.*.name, 'needs:compliance')
|
||||
if: github.event.action == 'edited' && contains(github.event.issue.labels.*.name, 'needs:compliance') # kilocode_change
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
@@ -13,7 +13,7 @@ on:
|
||||
jobs:
|
||||
build:
|
||||
name: Build docs site
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404 # kilocode_change
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6 # kilocode_change
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
# kilocode_change - new file
|
||||
name: docs-sync
|
||||
|
||||
# Daily bot: collects PRs merged to Kilo-Org/cloud and Kilo-Org/kilocode,
|
||||
# triages them for docs relevance, runs Kilo CLI headless to update
|
||||
# packages/kilo-docs, and maintains one rolling PR for human review.
|
||||
#
|
||||
# Security posture: scheduled/manual runs check out the dispatched ref and may
|
||||
# push/comment with write permissions and org secrets. PR runs (paths-limited to
|
||||
# this workflow and .github/docs-sync/**) execute branch code only in a
|
||||
# read-only, secretless `selftest` job that never pushes, comments, or calls an
|
||||
# LLM. `pull_request` (not `pull_request_target`) keeps fork tokens read-only.
|
||||
# State is derived from the bot's own PRs (watermark marker in the PR body), so
|
||||
# missed or failed runs self-heal on the next run.
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 7 * * *" # 07:00 UTC daily
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
since:
|
||||
description: "Override watermark (ISO date, e.g. 2026-07-20). Default: last processed-through marker, 72h fallback, 14d cap."
|
||||
required: false
|
||||
type: string
|
||||
dry_run:
|
||||
description: "Collect + triage only, no edits, no PR"
|
||||
type: boolean
|
||||
default: false
|
||||
pull_request:
|
||||
paths:
|
||||
- ".github/docs-sync/**"
|
||||
- ".github/workflows/docs-sync.yml"
|
||||
|
||||
permissions:
|
||||
contents: write # push the rolling branch, create the auto-docs label
|
||||
pull-requests: write # create/update the rolling PR
|
||||
issues: write # comment on the rolling PR
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.event_name == 'pull_request' && format('docs-sync-pr-{0}', github.event.pull_request.number) || 'docs-sync' }}
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
TRIAGE_MODEL: ${{ vars.DOCS_SYNC_TRIAGE_MODEL || 'kilo/moonshotai/kimi-k3' }}
|
||||
EDIT_MODEL: ${{ vars.DOCS_SYNC_EDIT_MODEL || 'kilo/moonshotai/kimi-k3' }}
|
||||
|
||||
jobs:
|
||||
selftest:
|
||||
if: github.repository == 'Kilo-Org/kilocode'
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "24"
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Run docs-sync selftest
|
||||
run: node .github/docs-sync/selftest.mjs
|
||||
|
||||
sync:
|
||||
if: github.repository == 'Kilo-Org/kilocode' && github.event_name != 'pull_request'
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
# Budget: 4 setup/collect + 90 triage + 120 edit + 2 verify + 10 fix + 2 upsert = 228 min, 12-minute reserve.
|
||||
# These are ceilings, not costs: a caught-up run triages ~2 chunks and edits
|
||||
# ~1 batch and finishes in ~25 min. The old 35/50 pair was the binding
|
||||
# constraint on backlog drain — run 30306629290 deferred 54 PRs untriaged and
|
||||
# 31 unedited purely on budget, with no attempt made. See the throughput note
|
||||
# in the PR description for the arithmetic.
|
||||
timeout-minutes: 240
|
||||
env:
|
||||
# Both are required: without KILO_ORG_ID the gateway bills the key
|
||||
# owner's personal balance (402 "Add credits") instead of the org.
|
||||
KILO_API_KEY: ${{ secrets.KILO_API_KEY }}
|
||||
KILO_ORG_ID: ${{ secrets.KILO_ORG_ID }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0 # prepare-branch merges main into the rolling branch
|
||||
|
||||
- name: Configure git identity
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "24"
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Run docs-sync selftest
|
||||
run: node .github/docs-sync/selftest.mjs
|
||||
|
||||
- name: Install Kilo CLI
|
||||
run: |
|
||||
npm install -g @kilocode/cli
|
||||
kilo --version
|
||||
|
||||
- name: Resolve watermark
|
||||
id: wm
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
INPUT_SINCE: ${{ inputs.since }}
|
||||
run: node .github/docs-sync/watermark.mjs
|
||||
|
||||
- name: Collect merged PRs
|
||||
id: collect
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: node .github/docs-sync/collect.mjs --since "${{ steps.wm.outputs.since }}"
|
||||
|
||||
- name: Triage merged PRs (LLM, chunked)
|
||||
id: triage
|
||||
if: steps.collect.outputs.count != '0'
|
||||
env:
|
||||
SINCE_OVERRIDE: ${{ steps.wm.outputs.since_override }}
|
||||
# Default 35 fit only 8 of 11 chunks on a 254-PR window. Headroom for
|
||||
# --auto making chunks slower now that the agent really runs commands.
|
||||
TRIAGE_BUDGET_MINUTES: "90"
|
||||
run: node .github/docs-sync/triage.mjs
|
||||
|
||||
- name: Filter docs-worthy PRs
|
||||
id: worthy
|
||||
if: steps.collect.outputs.count != '0'
|
||||
run: |
|
||||
node .github/docs-sync/filter-worthy.mjs \
|
||||
docs-sync-out/digest-full.json docs-sync-out/triage.json docs-sync-out/worthy.json
|
||||
count=$(node -p "require('./docs-sync-out/worthy.json').length")
|
||||
echo "count=$count" >> "$GITHUB_OUTPUT"
|
||||
if [ "$count" = "0" ]; then
|
||||
echo "No docs-worthy PRs in this window; skipping edit/verify/PR."
|
||||
fi
|
||||
|
||||
- name: Setup Bun
|
||||
if: (steps.worthy.outputs.count || '0') != '0' && inputs.dry_run != true
|
||||
uses: ./.github/actions/setup-bun
|
||||
|
||||
- name: Prepare rolling branch
|
||||
id: prep
|
||||
if: (steps.worthy.outputs.count || '0') != '0' && inputs.dry_run != true
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: node .github/docs-sync/prepare-branch.mjs
|
||||
|
||||
# After prepare-branch checks out the rolling branch and merges main, the
|
||||
# worktree holds main's scripts. Restore the dispatched ref's copies so a
|
||||
# branch-dispatch AC9 run actually exercises the fixed code. git restore
|
||||
# (not checkout) leaves them unstaged so upsert-pr's bare commit won't
|
||||
# include them in the docs PR.
|
||||
- name: Restore docs-sync scripts from the dispatched ref
|
||||
if: (steps.worthy.outputs.count || '0') != '0' && inputs.dry_run != true
|
||||
run: git restore --source=${{ github.sha }} -- .github/docs-sync
|
||||
|
||||
- name: Update docs (Kilo CLI, batched)
|
||||
if: (steps.worthy.outputs.count || '0') != '0' && inputs.dry_run != true
|
||||
continue-on-error: true
|
||||
env:
|
||||
# Default 50 fit only 4 of 11 batches. A healthy --auto batch is ~8 min,
|
||||
# and edit.mjs will not start a batch without EDIT_BATCH_TIMEOUT_MINUTES
|
||||
# (15) left, so 120 covers 14 batches = 70 PRs against ~5 worthy/day.
|
||||
EDIT_BUDGET_MINUTES: "120"
|
||||
run: node .github/docs-sync/edit.mjs
|
||||
|
||||
- name: Verify docs build and tests
|
||||
id: verify
|
||||
if: (steps.worthy.outputs.count || '0') != '0' && inputs.dry_run != true
|
||||
continue-on-error: true
|
||||
env:
|
||||
NEXT_PUBLIC_POSTHOG_KEY: ${{ secrets.POSTHOG_API_KEY }}
|
||||
run: |
|
||||
set -o pipefail
|
||||
{ bun run --filter @kilocode/kilo-docs build && bun run --filter @kilocode/kilo-docs test; } 2>&1 | tee docs-sync-out/verify.log
|
||||
|
||||
- name: Fix verify failures (one pass)
|
||||
id: fix
|
||||
if: steps.verify.outcome == 'failure'
|
||||
continue-on-error: true
|
||||
timeout-minutes: 10
|
||||
env:
|
||||
NEXT_PUBLIC_POSTHOG_KEY: ${{ secrets.POSTHOG_API_KEY }}
|
||||
run: |
|
||||
set -o pipefail
|
||||
# Headless kilo run auto-rejects every permission ask; the runner has no
|
||||
# user config granting bash, so without --auto the agent cannot run ordinary
|
||||
# shell commands against the repository.
|
||||
kilo run --auto "The docs build or tests failed. Read the attached docs-sync-out/verify.log and fix the packages/kilo-docs changes so they pass. Do not revert doc edits; fix them. Do not modify anything outside packages/kilo-docs." \
|
||||
-m "$EDIT_MODEL" --dir "$GITHUB_WORKSPACE" -f docs-sync-out/verify.log \
|
||||
| node .github/docs-sync/redact-stream.mjs \
|
||||
| tee -a docs-sync-out/edit-log.txt \
|
||||
|| echo "::warning::kilo fix pass exited nonzero; re-verifying anyway"
|
||||
# The rebuild below decides this step's outcome, not the agent's exit code.
|
||||
# Without the guard above, `set -o pipefail` + the default `bash -e` would
|
||||
# abort here once the CLI half of this PR ships: a mid-stream session error
|
||||
# (or an auto-rejected ask) exits 1, verify2.log is never written, and
|
||||
# `Re-verify status` reports VERIFIED=false even when the docs build fine.
|
||||
{ bun run --filter @kilocode/kilo-docs build && bun run --filter @kilocode/kilo-docs test; } 2>&1 | tee docs-sync-out/verify2.log
|
||||
|
||||
- name: Re-verify status
|
||||
id: verified
|
||||
if: (steps.worthy.outputs.count || '0') != '0' && inputs.dry_run != true
|
||||
env:
|
||||
VERIFY_OUTCOME: ${{ steps.verify.outcome }}
|
||||
FIX_OUTCOME: ${{ steps.fix.outcome }}
|
||||
run: |
|
||||
if [ "$VERIFY_OUTCOME" = "success" ] || [ "$FIX_OUTCOME" = "success" ]; then
|
||||
echo "ok=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "ok=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Upsert rolling PR
|
||||
if: (steps.worthy.outputs.count || '0') != '0' && inputs.dry_run != true
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
PROCESSED_THROUGH: ${{ steps.wm.outputs.now }}
|
||||
SINCE: ${{ steps.wm.outputs.since }}
|
||||
SINCE_OVERRIDE: ${{ steps.wm.outputs.since_override }}
|
||||
BRANCH: ${{ steps.prep.outputs.branch }}
|
||||
PREP_MODE: ${{ steps.prep.outputs.mode }}
|
||||
PR_NUMBER: ${{ steps.prep.outputs.pr_number }}
|
||||
VERIFIED: ${{ steps.verified.outputs.ok }}
|
||||
run: node .github/docs-sync/upsert-pr.mjs
|
||||
|
||||
- name: Upload run artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: docs-sync-out
|
||||
path: docs-sync-out/
|
||||
retention-days: 14
|
||||
if-no-files-found: ignore
|
||||
@@ -57,14 +57,24 @@ jobs:
|
||||
BUILD_LOG=$(mktemp)
|
||||
trap 'rm -f "$BUILD_LOG"' EXIT
|
||||
|
||||
# Build with fakeHash to trigger hash mismatch and reveal correct hash
|
||||
nix build ".#packages.${SYSTEM}.node_modules_updater" --no-link 2>&1 | tee "$BUILD_LOG" || true
|
||||
HASH=""
|
||||
MAX_ATTEMPTS=3
|
||||
for ((ATTEMPT = 1; ATTEMPT <= MAX_ATTEMPTS; ATTEMPT++)); do
|
||||
# Build with fakeHash to trigger hash mismatch and reveal correct hash
|
||||
nix build ".#packages.${SYSTEM}.node_modules_updater" --no-link 2>&1 | tee "$BUILD_LOG" || true
|
||||
|
||||
# Extract hash from build log with portability
|
||||
HASH="$(nix run --inputs-from . nixpkgs#gnugrep -- -oP 'got:\s*\Ksha256-[A-Za-z0-9+/=]+' "$BUILD_LOG" | tail -n1 || true)"
|
||||
HASH="$(nix run --inputs-from . nixpkgs#gnugrep -- -oP 'got:\s*\Ksha256-[A-Za-z0-9+/=]+' "$BUILD_LOG" | tail -n1 || true)"
|
||||
|
||||
[ -n "$HASH" ] && break
|
||||
|
||||
if [ "$ATTEMPT" -lt "$MAX_ATTEMPTS" ]; then
|
||||
echo "::warning::Attempt ${ATTEMPT}/${MAX_ATTEMPTS} produced no hash for ${SYSTEM}; retrying in $((ATTEMPT * 10))s"
|
||||
sleep $((ATTEMPT * 10))
|
||||
fi
|
||||
done
|
||||
|
||||
if [ -z "$HASH" ]; then
|
||||
echo "::error::Failed to compute hash for ${SYSTEM}"
|
||||
echo "::error::Failed to compute hash for ${SYSTEM} after ${MAX_ATTEMPTS} attempts"
|
||||
cat "$BUILD_LOG"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -0,0 +1,341 @@
|
||||
# kilocode_change - new file
|
||||
name: publish-jetbrains-bundled
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
pr:
|
||||
description: Merged JetBrains release PR number to bundle
|
||||
required: true
|
||||
type: string
|
||||
merge_commit:
|
||||
description: Merge commit SHA from the reviewed release PR
|
||||
required: true
|
||||
type: string
|
||||
|
||||
concurrency:
|
||||
group: publish-jetbrains-bundled-pr-${{ inputs.pr }}
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
if: github.repository == 'Kilo-Org/kilocode'
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
outputs:
|
||||
version: ${{ steps.release.outputs.version }}
|
||||
kind: ${{ steps.release.outputs.kind }}
|
||||
tag: ${{ steps.release.outputs.tag }}
|
||||
channel: ${{ steps.release.outputs.marketplace_channel }}
|
||||
steps:
|
||||
- name: Checkout trusted validation scripts
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: main
|
||||
|
||||
- name: Setup Bun for validation
|
||||
uses: ./.github/actions/setup-bun
|
||||
|
||||
- name: Checkout merged release PR for validation
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
path: release
|
||||
persist-credentials: false
|
||||
ref: ${{ inputs.merge_commit }}
|
||||
|
||||
- name: Validate release PR and tag
|
||||
id: release
|
||||
working-directory: release
|
||||
run: bun ../script/jetbrains-release-validate.ts --pr "$PR_NUMBER"
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
PR_NUMBER: ${{ inputs.pr }}
|
||||
|
||||
bundle:
|
||||
needs: validate
|
||||
if: github.repository == 'Kilo-Org/kilocode'
|
||||
runs-on: blacksmith-8vcpu-ubuntu-2404
|
||||
permissions:
|
||||
actions: read
|
||||
contents: write
|
||||
outputs:
|
||||
version: ${{ needs.validate.outputs.version }}
|
||||
kind: ${{ needs.validate.outputs.kind }}
|
||||
steps:
|
||||
- name: Checkout merged release PR metadata
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
ref: ${{ inputs.merge_commit }}
|
||||
|
||||
- name: Save reviewed release metadata
|
||||
run: |
|
||||
cp packages/kilo-jetbrains/CHANGELOG.md "$RUNNER_TEMP/jetbrains-CHANGELOG.md"
|
||||
cp packages/kilo-jetbrains/gradle.properties "$RUNNER_TEMP/jetbrains-gradle.properties"
|
||||
|
||||
- name: Checkout release tag
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ needs.validate.outputs.tag }}
|
||||
|
||||
- name: Restore reviewed release metadata
|
||||
run: |
|
||||
cp "$RUNNER_TEMP/jetbrains-CHANGELOG.md" packages/kilo-jetbrains/CHANGELOG.md
|
||||
cp "$RUNNER_TEMP/jetbrains-gradle.properties" packages/kilo-jetbrains/gradle.properties
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "24"
|
||||
|
||||
- name: Setup Bun
|
||||
uses: ./.github/actions/setup-bun
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install
|
||||
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21"
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@v4
|
||||
|
||||
- name: Install build tools
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y patchelf zip unzip
|
||||
curl --fail --location \
|
||||
https://ziglang.org/download/0.14.0/zig-linux-x86_64-0.14.0.tar.xz \
|
||||
--output "$RUNNER_TEMP/zig.tar.xz"
|
||||
echo "473ec26806133cf4d1918caf1a410f8403a13d979726a9045b421b685031a982 $RUNNER_TEMP/zig.tar.xz" | sha256sum --check --status
|
||||
tar -xJf "$RUNNER_TEMP/zig.tar.xz" -C "$RUNNER_TEMP"
|
||||
echo "$RUNNER_TEMP/zig-linux-x86_64-0.14.0" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Validate signing secrets
|
||||
run: |
|
||||
missing=0
|
||||
for name in JETBRAINS_CERTIFICATE_CHAIN JETBRAINS_PRIVATE_KEY JETBRAINS_PRIVATE_KEY_PASSWORD; do
|
||||
if [[ -z "${!name}" ]]; then
|
||||
echo "Missing required secret: $name" >&2
|
||||
missing=1
|
||||
fi
|
||||
done
|
||||
exit "$missing"
|
||||
env:
|
||||
JETBRAINS_CERTIFICATE_CHAIN: ${{ secrets.JETBRAINS_CERTIFICATE_CHAIN }}
|
||||
JETBRAINS_PRIVATE_KEY: ${{ secrets.JETBRAINS_PRIVATE_KEY }}
|
||||
JETBRAINS_PRIVATE_KEY_PASSWORD: ${{ secrets.JETBRAINS_PRIVATE_KEY_PASSWORD }}
|
||||
|
||||
- name: Write signing secrets to temp files
|
||||
run: |
|
||||
dir="$RUNNER_TEMP/jetbrains-signing"
|
||||
mkdir -m 700 -p "$dir"
|
||||
chain="$dir/certificate-chain.pem"
|
||||
key="$dir/private-key.pem"
|
||||
umask 077
|
||||
printf '%s' "$JETBRAINS_CERTIFICATE_CHAIN" > "$chain"
|
||||
printf '%s' "$JETBRAINS_PRIVATE_KEY" > "$key"
|
||||
chmod 600 "$chain" "$key"
|
||||
echo "JETBRAINS_CERTIFICATE_CHAIN_FILE=$chain" >> "$GITHUB_ENV"
|
||||
echo "JETBRAINS_PRIVATE_KEY_FILE=$key" >> "$GITHUB_ENV"
|
||||
env:
|
||||
JETBRAINS_CERTIFICATE_CHAIN: ${{ secrets.JETBRAINS_CERTIFICATE_CHAIN }}
|
||||
JETBRAINS_PRIVATE_KEY: ${{ secrets.JETBRAINS_PRIVATE_KEY }}
|
||||
|
||||
- name: Build signed bundled plugin
|
||||
working-directory: packages/kilo-jetbrains
|
||||
run: |
|
||||
args=(
|
||||
-Pproduction=true
|
||||
-Pkilo.version="$VERSION"
|
||||
-Pkilo.channel="$CHANNEL"
|
||||
-Pkilo.cli.bundled=true
|
||||
)
|
||||
./gradlew clean buildPlugin signPlugin verifyPluginSignature verifyPlugin "${args[@]}"
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
VERSION: ${{ needs.validate.outputs.version }}
|
||||
CHANNEL: ${{ needs.validate.outputs.channel }}
|
||||
JETBRAINS_PRIVATE_KEY_PASSWORD: ${{ secrets.JETBRAINS_PRIVATE_KEY_PASSWORD }}
|
||||
|
||||
- name: Remove signing secret temp files
|
||||
if: always()
|
||||
run: rm -rf "$RUNNER_TEMP/jetbrains-signing"
|
||||
|
||||
- name: Resolve bundled archive
|
||||
id: archive
|
||||
run: |
|
||||
mapfile -t signed < <(compgen -G "packages/kilo-jetbrains/build/distributions/*-signed.zip")
|
||||
if [[ "${#signed[@]}" -ne 1 ]]; then
|
||||
echo "Expected exactly one signed bundled JetBrains plugin ZIP, found ${#signed[@]}." >&2
|
||||
printf '%s\n' "${signed[@]}" >&2
|
||||
exit 1
|
||||
fi
|
||||
asset="kilo-code-${VERSION}-bundled.zip"
|
||||
dest="packages/kilo-jetbrains/build/release/$asset"
|
||||
mkdir -p "$(dirname "$dest")"
|
||||
cp "${signed[0]}" "$dest"
|
||||
echo "asset=$asset" >> "$GITHUB_OUTPUT"
|
||||
echo "path=$dest" >> "$GITHUB_OUTPUT"
|
||||
env:
|
||||
VERSION: ${{ needs.validate.outputs.version }}
|
||||
|
||||
- name: Upload bundled ZIP to GitHub Release
|
||||
run: gh release upload "$TAG" "$ARCHIVE" --clobber --repo "$GITHUB_REPOSITORY"
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TAG: ${{ needs.validate.outputs.tag }}
|
||||
ARCHIVE: ${{ steps.archive.outputs.path }}
|
||||
|
||||
- name: Resolve bundled asset URL
|
||||
id: asset
|
||||
run: |
|
||||
url="$(gh release view "$TAG" --json assets --jq '.assets[] | select(.name == env.ASSET) | .url' --repo "$GITHUB_REPOSITORY")"
|
||||
if [[ -z "$url" ]]; then
|
||||
echo "Could not resolve GitHub Release URL for $ASSET" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "url=$url" >> "$GITHUB_OUTPUT"
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TAG: ${{ needs.validate.outputs.tag }}
|
||||
ASSET: ${{ steps.archive.outputs.asset }}
|
||||
|
||||
- name: Generate stable plugin repository XML
|
||||
if: needs.validate.outputs.kind == 'stable'
|
||||
run: |
|
||||
mkdir -p pages/jetbrains
|
||||
python3 <<'PY'
|
||||
import html
|
||||
import io
|
||||
import os
|
||||
import zipfile
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
archive = os.environ["ARCHIVE"]
|
||||
asset = os.environ["ASSET_URL"]
|
||||
version = os.environ["VERSION"]
|
||||
|
||||
def plugin_xml(path):
|
||||
with zipfile.ZipFile(path) as zip:
|
||||
for name in zip.namelist():
|
||||
if name.endswith("META-INF/plugin.xml"):
|
||||
return zip.read(name)
|
||||
for name in zip.namelist():
|
||||
if not name.endswith(".jar"):
|
||||
continue
|
||||
with zipfile.ZipFile(io.BytesIO(zip.read(name))) as jar:
|
||||
for item in jar.namelist():
|
||||
if item.endswith("META-INF/plugin.xml"):
|
||||
return jar.read(item)
|
||||
raise SystemExit("bundled plugin ZIP did not contain META-INF/plugin.xml")
|
||||
|
||||
root = ET.fromstring(plugin_xml(archive))
|
||||
|
||||
def text(name, default=""):
|
||||
item = root.find(name)
|
||||
return item.text.strip() if item is not None and item.text else default
|
||||
|
||||
def cdata(value):
|
||||
return "<![CDATA[" + value.replace("]]>", "]]]]><![CDATA[>") + "]]>"
|
||||
|
||||
plugin = text("id", "ai.kilocode.jetbrains")
|
||||
name = text("name", "Kilo Code")
|
||||
vendor = text("vendor", "Kilo Code")
|
||||
desc = text("description")
|
||||
notes = text("change-notes")
|
||||
idea = root.find("idea-version")
|
||||
attrs = ""
|
||||
if idea is not None:
|
||||
since = idea.attrib.get("since-build")
|
||||
until = idea.attrib.get("until-build")
|
||||
if since:
|
||||
attrs += f' since-build="{html.escape(since)}"'
|
||||
if until:
|
||||
attrs += f' until-build="{html.escape(until)}"'
|
||||
|
||||
xml = [
|
||||
'<?xml version="1.0" encoding="UTF-8"?>',
|
||||
'<plugins>',
|
||||
f' <plugin id="{html.escape(plugin)}" version="{html.escape(version)}" url="{html.escape(asset)}">',
|
||||
f' <name>{html.escape(name)}</name>',
|
||||
f' <vendor>{html.escape(vendor)}</vendor>',
|
||||
f' <idea-version{attrs}/>',
|
||||
]
|
||||
if desc:
|
||||
xml.append(f' <description>{cdata(desc)}</description>')
|
||||
if notes:
|
||||
xml.append(f' <change-notes>{cdata(notes)}</change-notes>')
|
||||
xml.extend([' </plugin>', '</plugins>', ''])
|
||||
with open("pages/jetbrains/updatePlugins.xml", "w", encoding="utf-8") as file:
|
||||
file.write("\n".join(xml))
|
||||
PY
|
||||
env:
|
||||
ARCHIVE: ${{ steps.archive.outputs.path }}
|
||||
ASSET_URL: ${{ steps.asset.outputs.url }}
|
||||
VERSION: ${{ needs.validate.outputs.version }}
|
||||
|
||||
- name: Upload stable Pages source
|
||||
if: needs.validate.outputs.kind == 'stable'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: jetbrains-pages-${{ needs.validate.outputs.version }}
|
||||
path: pages
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Upload workflow artifact
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: kilo-jetbrains-bundled-${{ needs.validate.outputs.version }}
|
||||
path: |
|
||||
packages/kilo-jetbrains/build/release/*.zip
|
||||
pages/jetbrains/updatePlugins.xml
|
||||
if-no-files-found: ignore
|
||||
|
||||
pages:
|
||||
needs: bundle
|
||||
if: needs.bundle.outputs.kind == 'stable'
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
permissions:
|
||||
actions: read
|
||||
id-token: write
|
||||
pages: write
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
steps:
|
||||
- name: Download stable Pages source
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: jetbrains-pages-${{ needs.bundle.outputs.version }}
|
||||
path: pages
|
||||
|
||||
- name: Configure Pages
|
||||
uses: actions/configure-pages@v5
|
||||
|
||||
- name: Upload Pages artifact
|
||||
uses: actions/upload-pages-artifact@v4
|
||||
with:
|
||||
path: pages
|
||||
|
||||
- name: Deploy Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v4
|
||||
@@ -23,6 +23,7 @@ concurrency:
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
actions: write
|
||||
contents: write
|
||||
pull-requests: read
|
||||
|
||||
@@ -199,6 +200,19 @@ jobs:
|
||||
ARCHIVE: ${{ steps.archive.outputs.path }}
|
||||
NOTES: packages/kilo-jetbrains/build/release-notes.md
|
||||
|
||||
- name: Dispatch bundled GitHub release build
|
||||
continue-on-error: true
|
||||
run: |
|
||||
gh workflow run publish-jetbrains-bundled.yml \
|
||||
--repo "$GITHUB_REPOSITORY" \
|
||||
--ref main \
|
||||
-f pr="$PR_NUMBER" \
|
||||
-f merge_commit="$MERGE_COMMIT"
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number || inputs.pr }}
|
||||
MERGE_COMMIT: ${{ github.event.pull_request.merge_commit_sha || inputs.merge_commit }}
|
||||
|
||||
- name: Upload workflow artifact
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
|
||||
@@ -258,7 +258,7 @@ jobs:
|
||||
name: Validate CLI (windows-${{ matrix.arch }})
|
||||
needs: build-cli
|
||||
runs-on: ${{ matrix.runner }}
|
||||
timeout-minutes: 20
|
||||
timeout-minutes: 20 # kilocode_change
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
|
||||
@@ -42,8 +42,6 @@ jobs:
|
||||
needs: changes
|
||||
if: github.event_name == 'workflow_dispatch' || needs.changes.outputs.jetbrains == 'true'
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
container:
|
||||
image: ghcr.io/kilo-org/build/jetbrains:24.04
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
@@ -56,12 +54,27 @@ jobs:
|
||||
- name: Mark workspace as git-safe
|
||||
run: git config --global --add safe.directory "$GITHUB_WORKSPACE"
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
bun-version-file: package.json
|
||||
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21"
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@v4
|
||||
with:
|
||||
cache-read-only: ${{ github.ref != 'refs/heads/main' }}
|
||||
|
||||
- name: Run JetBrains unit tests
|
||||
run: bun script/test-ci.ts
|
||||
working-directory: packages/kilo-jetbrains
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Publish JetBrains unit reports
|
||||
if: always()
|
||||
|
||||
@@ -21,7 +21,7 @@ on:
|
||||
jobs:
|
||||
unit:
|
||||
name: unit tests
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404 # kilocode_change
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user