Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 117 additions & 0 deletions .github/v2-port/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
# v2-port tracker

Files a `[v2-port]` tracking issue for every merged PR that carries a Kilo-specific change to
`packages/opencode/`, so the behavior can be re-implemented or verified in the opencode v2 rewrite. It is
the automated form of the `pr-track-v1` skill.

## What triggers a run

`.github/workflows/v2-port-tracker.yml` runs after a PR merges into a base branch, when the PR touches
`packages/opencode/**`. `gate.mjs` then decides whether the PR actually qualifies:

| Outcome | Condition |
|---|---|
| Skipped | Not merged, bot author, `no-v2-port` label, or already carries `core-v1-changes-port` |
| Qualifies | Diff touches a `packages/opencode/` path with `kilocode` in a segment or filename, or adds a `kilocode_change` marker line in a `packages/opencode/` file |
| Skipped | Anything else — pure upstream merges, formatting, dependency bumps, unmarked shared-code edits |

The gate is deterministic and costs nothing, so most merged PRs never reach the model.

## Where the code lives

| File | Role |
|---|---|
| `gate.mjs` | Qualification + writes `v2-port-out/pr.json` and `pr.diff`. `gate.test.mjs` covers it. |
| `prompt.md` | The agent's instructions. Loads the vendored `.kilo/skills/pr-track-v1/SKILL.md` and applies the CI-only deltas. |
| `issue.mjs` | Draft validation, dedup, label ensure, issue create, assignee resolution, PR back-link. `issue.test.mjs` covers it. |

The skill itself lives at `.kilo/skills/pr-track-v1/SKILL.md` so the interactive `/pr-track-v1` command and
CI share one source of truth. CI-only differences (author assignment, no `gh`, no interaction) live in
`prompt.md`, never in the skill.

## Security posture

The agent is steered by untrusted PR text — titles, bodies, diffs, and review comments from any
contributor. So:

- The workflow runs on `pull_request_target` and checks out the **base branch tip**, never the PR head ref.
No contributor code is ever executed, and checkout uses `persist-credentials: false` so the workflow
token is not written into the agent's workspace.
- The agent step receives **no `GH_TOKEN`**. `KILO_CONFIG_CONTENT` denies `bash`, `webfetch`, `websearch`,
`task`, `external_directory`, and the memory/recall tools, and scopes writes to `v2-port-out/**`. The PR
metadata is pre-fetched and attached, so the agent needs no network and no shell.
- Permissions are explicit **denies**, not `--auto`. An inherited `ask` default is auto-rejected headlessly,
which aborts the whole run (`packages/opencode/src/cli/cmd/run.ts:1032-1039`); an explicit `deny` returns a
tool error the agent can recover from. That is why the deny list is spelled out rather than relying on
defaults, and why the agent never gets `--auto`: a missing allow fails loudly instead of silently
widening access.
- Every GitHub mutation happens in `issue.mjs` afterwards, with the job token.
- Runs triggered by a `pull_request` (only when someone edits this directory or the workflow) execute in a
read-only, secretless `selftest` job that never calls an LLM.

Worst case for a crafted PR is a misleading issue body, which a human reviews. That is the intended
trade-off, and it is why the agent never gets `--auto` — a missing allow entry fails loudly instead of
silently widening access.

## Setup

Both secrets already exist for `docs-sync`:

- `KILO_API_KEY` — Kilo Gateway key.
- `KILO_ORG_ID` — required; without it the gateway bills the key owner's personal balance.

Optional repository variables:

| Variable | Default | Purpose |
|---|---|---|
| `V2_PORT_MODEL` | `kilo/anthropic/claude-sonnet-4.6` | Model that writes the issue. |
| `V2_PORT_VARIANT` | `high` | Reasoning effort. |

## Operating it

Dry run against a merged PR, no issue created:

```bash
gh workflow run v2-port-tracker.yml -f pr_number=<N> -f dry_run=true
```

Real run:

```bash
gh workflow run v2-port-tracker.yml -f pr_number=<N>
```

Re-running is safe. An existing tracking issue is detected by the `<!-- v2-port:pr-<N> -->` marker in its
body, and the PR back-link is upserted rather than duplicated.

## Conventions

- Add `no-v2-port` to a PR to stop it being tracked. Use it for Kilo-specific changes that intentionally do
not need a v2 counterpart.
- Add `core-v1-changes-port` to a PR that is already tracked by hand; the gate then skips it.
- Every filed issue carries `core-v1-changes-port` and is assigned to the PR author. If the author has no
repo access (external contributors, bots), the issue is created **unassigned** and the run logs a
`::warning::`.

## Failure handling

| Situation | Result |
|---|---|
| Gate skip, or the agent writes `skip.txt` | Green run with a summary line |
| Agent fails both attempts | Red run; `workflow_dispatch` replays it |
| Draft fails validation (missing section, unresolved placeholder, bad title) | Red run, **nothing created** |
| API error mid-way | Red run; re-run dedups and upserts |

Artifacts (`pr.json`, `pr.diff`, the generated draft, and `draft-log-*.txt`) are uploaded on every run
for 14 days.

## Tests

```bash
node .github/v2-port/gate.test.mjs
node .github/v2-port/issue.test.mjs
```

`issue.test.mjs` drives the real `issue.mjs` against a local stub of the GitHub API via the
`DOCS_SYNC_API_BASE` hook, so dedup, assignee resolution, and comment idempotency are exercised as real
code. The same two commands run in the workflow's `selftest` job.
198 changes: 198 additions & 0 deletions .github/v2-port/gate.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
// kilocode_change - new file

/**
* Gate for the v2-port tracker: decides whether a merged PR carries a
* Kilo-specific change to packages/opencode/ that the opencode v2 rewrite could
* plausibly miss, and writes the agent's input files.
*
* A PR qualifies when its diff touches a path under packages/opencode/ that has
* `kilocode` in a path segment or filename, or when it adds a `kilocode_change`
* marker line in a packages/opencode/ file. That is the same signal the
* annotations check already enforces, so upstream merges, formatting, and
* dependency bumps are excluded without an LLM call.
*
* Writes v2-port-out/pr.json (full PR metadata) and v2-port-out/pr.diff
* (truncated) for the agent step. Qualification runs on the FULL diff; only the
* file handed to the agent is capped.
*
* Env: GH_TOKEN, GITHUB_REPOSITORY, PR_NUMBER.
* Uses the `gh` CLI for complete PR metadata plus `gh pr diff` (the files API
* drops patches for large files and caps at 300 files).
*/

import { spawnSync } from "node:child_process"
import fs from "node:fs"
import path from "node:path"
import { pathToFileURL } from "node:url"
import { appendOutput, appendSummary, repo } from "../docs-sync/lib.mjs"

const OUT_DIR = "v2-port-out"
const DIFF_CAP_BYTES = 200 * 1024
const SCOPE = "packages/opencode/"
const MARKER = "kilocode_change"
// A PR already tracked by hand carries the label the tracker would add.
const TRACKED_LABEL = "core-v1-changes-port"
const SKIP_LABEL = "no-v2-port"

/**
* Parse a unified diff into `{ path, added }` per file. Path comes from the
* `+++ b/...` header (falling back to the `diff --git` line for deletions, where
* the new side is /dev/null).
*/
export function parseDiff(text) {
const files = []
let cur = null
for (const line of String(text ?? "").split("\n")) {
if (line.startsWith("diff --git ")) {
const m = line.match(/^diff --git a\/(.+?) b\/(.+)$/)
cur = { path: m ? m[2] : line.slice("diff --git ".length).trim(), added: [] }
files.push(cur)
continue
}
if (!cur) continue
if (line.startsWith("+++ ")) {
const p = line.slice(4).trim()
if (p && p !== "/dev/null") cur.path = p.replace(/^b\//, "")
continue
}
if (line.startsWith("+")) cur.added.push(line.slice(1))
}
return files
}

/**
* Qualification rule. `includes("kilocode")` covers src/kilocode/**, test/kilocode/**,
* and any Kilo-named file, matching the annotation checker's exempt-path rule.
*/
export function qualifyDiff(text) {
const files = parseDiff(text).filter((f) => f.path.startsWith(SCOPE))
if (files.length === 0) {
return { qualifies: false, reason: `diff touches nothing under ${SCOPE}`, matches: [] }
}
const paths = files.filter((f) => f.path.includes("kilocode")).map((f) => f.path)
if (paths.length > 0) {
return { qualifies: true, reason: `Kilo-owned opencode path changed: ${paths[0]}`, matches: paths }
}
const markers = files.filter((f) => f.added.some((l) => l.includes(MARKER))).map((f) => f.path)
if (markers.length > 0) {
return { qualifies: true, reason: `added a ${MARKER} marker in ${markers[0]}`, matches: markers }
}
return { qualifies: false, reason: `no Kilo-specific change under ${SCOPE}`, matches: [] }
}

/** Skip reason for PR metadata that should never reach the agent, or null. */
export function skipReason(pr, labels = []) {
if (!pr) return "PR not found"
if (!pr.mergedAt) return "PR is not merged"
const login = pr.author?.login ?? "unknown"
if (pr.author?.is_bot === true) return `author ${login} is a bot`
if (labels.includes(SKIP_LABEL)) return `${SKIP_LABEL} label`
if (labels.includes(TRACKED_LABEL)) return `${TRACKED_LABEL} label already applied`
return null
}

/**
* Cap the diff handed to the agent, cutting on a line boundary so the last line
* is never half a hunk. Appends an explicit trailer so a truncated input is
* never mistaken for the whole change.
*/
export function truncateDiff(text, cap = DIFF_CAP_BYTES) {
const buf = Buffer.from(String(text ?? ""), "utf8")
if (buf.length <= cap) return { text: String(text ?? ""), truncated: false, bytes: buf.length }
const head = buf.subarray(0, cap).toString("utf8")
const cut = head.lastIndexOf("\n")
const safe = cut > 0 ? head.slice(0, cut) : head
return { text: `${safe}\n\n[diff truncated: ${cap} of ${buf.length} bytes]\n`, truncated: true, bytes: buf.length }
}

function runGh(args, label) {
const res = spawnSync("gh", args, { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 })
if (res.error) throw new Error(`${label} could not start: ${res.error.message}`)
if (res.status !== 0) throw new Error(`${label} failed (exit ${res.status}): ${String(res.stderr ?? "").trim()}`)
return res.stdout ?? ""
}

function readPr(number) {
const fields = [
"number",
"url",
"title",
"body",
"state",
"mergedAt",
"mergeCommit",
"author",
"labels",
"files",
"commits",
"comments",
"reviews",
"closingIssuesReferences",
].join(",")
return JSON.parse(runGh(["pr", "view", String(number), "--repo", repo(), "--json", fields], `gh pr view #${number}`))
}

function readDiff(number) {
return runGh(["pr", "diff", String(number), "--repo", repo(), "--patch"], `gh pr diff #${number}`)
}

async function main() {
const number = Number(process.env.PR_NUMBER)
if (!Number.isInteger(number) || number <= 0) throw new Error(`PR_NUMBER is required, got ${process.env.PR_NUMBER}`)

fs.mkdirSync(OUT_DIR, { recursive: true })
const pr = readPr(number)
fs.writeFileSync(path.join(OUT_DIR, "pr.json"), JSON.stringify(pr, null, 2))

appendOutput("author", pr.author?.login ?? "")
appendOutput("title", pr.title ?? "")

const labels = (pr.labels ?? []).map((l) => l.name)
const skip = skipReason(pr, labels)
if (skip) {
console.log(`skip #${number}: ${skip}`)
appendOutput("qualifies", "false")
appendOutput("skip_reason", skip)
appendSummary(`### v2-port tracker\n\nSkipped PR #${number}: ${skip}\n`)
return
}

const full = readDiff(number)
if (!full.trim()) {
console.log(`skip #${number}: empty diff`)
appendOutput("qualifies", "false")
appendOutput("skip_reason", "empty diff")
appendSummary(`### v2-port tracker\n\nSkipped PR #${number}: empty diff\n`)
return
}

const verdict = qualifyDiff(full)
const capped = truncateDiff(full)
fs.writeFileSync(path.join(OUT_DIR, "pr.diff"), capped.text)
if (capped.truncated) {
console.warn(`::warning::diff truncated to ${DIFF_CAP_BYTES} of ${capped.bytes} bytes for the agent`)
}

appendOutput("qualifies", verdict.qualifies ? "true" : "false")
appendOutput("skip_reason", verdict.qualifies ? "" : verdict.reason)
console.log(verdict.qualifies ? `qualifies #${number}: ${verdict.reason}` : `skip #${number}: ${verdict.reason}`)
appendSummary(
[
"### v2-port tracker",
"",
`- PR #${number}: \`${verdict.qualifies ? "qualifies" : "skipped"}\` — ${verdict.reason}`,
`- diff: ${capped.bytes} bytes${capped.truncated ? " (truncated for the agent)" : ""}`,
verdict.matches.length > 0 ? `- matched: ${verdict.matches.slice(0, 5).join(", ")}` : "",
]
.filter(Boolean)
.join("\n") + "\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)
})
}
Loading
Loading