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
5 changes: 5 additions & 0 deletions .changeset/fix-failed-checks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"kilo-code": minor
---

Send failed CI checks to the agent from the PR panel, with compact feedback and on-demand log retrieval.
7 changes: 6 additions & 1 deletion packages/kilo-vscode/src/agent-manager/pr/am-pr-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -292,7 +292,12 @@ export function signature(pr: PRStatus): string {
pr.title,
pr.state,
pr.review,
[pr.checks.status, pr.checks.passed, pr.checks.total],
[
pr.checks.status,
pr.checks.passed,
pr.checks.total,
pr.checks.checks.map((check) => [check.name, check.status, check.url ?? "", check.duration ?? ""]),
],
pr.reviewers.map((r) => [r.login, r.state]),
pr.body ?? "",
[
Expand Down
27 changes: 25 additions & 2 deletions packages/kilo-vscode/src/shared/review-comments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,12 +33,23 @@ export interface PRReviewCommentData {
replies?: PRReviewReply[]
}

export type ReviewCommentEntry = ReviewCommentData | PRReviewCommentData
export interface CIReviewCommentData {
id: string
origin: "ci"
title: string
body: string
}

export type ReviewCommentEntry = ReviewCommentData | PRReviewCommentData | CIReviewCommentData

export function isPRReviewComment(item: ReviewCommentEntry): item is PRReviewCommentData {
return "origin" in item && item.origin === "pr"
}

export function isCIReviewComment(item: ReviewCommentEntry): item is CIReviewCommentData {
return "origin" in item && item.origin === "ci"
}

export interface ReviewMessageData {
version: 1
comments: ReviewCommentEntry[]
Expand Down Expand Up @@ -89,14 +100,16 @@ function formatPR(comment: PRReviewCommentData): string {

export function formatReviewCommentMarkdown(comment: ReviewCommentEntry): string {
if (isPRReviewComment(comment)) return formatPR(comment)
if (isCIReviewComment(comment)) return `CI feedback: **${escapeInline(comment.title)}**\n${comment.body}`
const lines = [`**${escapeInline(comment.file)}** (line ${comment.line}):`]
if (comment.selectedText) lines.push(...fenced(comment.selectedText))
lines.push(comment.comment)
return lines.join("\n")
}

export function formatReviewCommentsMarkdown(comments: ReviewCommentEntry[]): string {
const lines = ["## Review Comments", ""]
const ci = comments.length > 0 && comments.every(isCIReviewComment)
const lines = [ci ? "## CI Feedback" : "## Review Comments", ""]
for (const item of comments) {
lines.push(formatReviewCommentMarkdown(item), "")
}
Expand Down Expand Up @@ -212,10 +225,19 @@ function parsePR(item: Record<string, unknown>): PRReviewCommentData | undefined
}
}

function parseCI(item: Record<string, unknown>): CIReviewCommentData | undefined {
const id = text(item.id, 512)
const title = text(item.title, 256)
const body = text(item.body, 16_000)
if (!id || !title || body === undefined) return undefined
return { id, origin: "ci", title, body }
}

function parseComment(value: unknown): ReviewCommentEntry | undefined {
const item = record(value)
if (!item) return undefined
if (item.origin === "pr") return parsePR(item)
if (item.origin === "ci") return parseCI(item)
if (item.origin !== undefined) return undefined

const id = text(item.id, 512)
Expand All @@ -233,6 +255,7 @@ function parseComment(value: unknown): ReviewCommentEntry | undefined {
}

function weight(item: ReviewCommentEntry): number {
if (isCIReviewComment(item)) return item.id.length + item.title.length + item.body.length
if (!isPRReviewComment(item))
return item.id.length + item.file.length + item.comment.length + item.selectedText.length
const replies = (item.replies ?? []).reduce(
Expand Down
56 changes: 56 additions & 0 deletions packages/kilo-vscode/tests/fixtures/pr-comments-render.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -715,3 +715,59 @@ for (const file of ["old.ts", "renamed.ts"]) {
assert.equal(navigation.review.focus(scope), focused)
}
navigation.dispose()

const { PRChecks } = await import("../../webview-ui/agent-manager/pr/PRChecks")
const { summarize } = await import("../../src/agent-manager/pr/am-pr-utils")
const third = document.createElement("div")
document.body.append(third)
const [prState, setPrState] = createSignal<PRStatus>({
...base,
checks: summarize([
{ name: "Typecheck", status: "failure", url: "https://github.com/example/repo/actions/runs/100/job/200" },
{ name: "Tests", status: "success" },
{ name: "Lint", status: "pending" },
]),
})
const cleanup = render(
() => (
<VSCodeProvider>
<LanguageProvider>
<PRChecks pr={prState()} />
</LanguageProvider>
</VSCodeProvider>
),
third,
)
await window.happyDOM.waitUntilComplete()
const fix = () => third.querySelector<HTMLButtonElement>(".am-pr-checks-fix")
assert.equal(fix()?.textContent?.trim(), "Fix with Kilo")
assert.equal(fix()?.querySelector('[data-component="icon"]'), null)
const before = sent.length
fix()!.click()
const feedback = sent.at(-1) as {
autoSend: boolean
comments: import("../../src/shared/review-comments").CIReviewCommentData[]
}
assert.equal(sent.length, before + 1)
assert.equal(feedback.autoSend, true)
assert.equal(feedback.comments[0]?.origin, "ci")
// Draft removal and session changes are outside PRChecks. Unchanged checks
// must remain sendable without remounting or waiting for another CI run.
assert.equal(fix()?.disabled, false)
assert.equal(fix()?.textContent?.trim(), "Fix with Kilo")
fix()!.click()
assert.equal(sent.length, before + 2)
assert.deepEqual(sent.at(-1), feedback)
fix()!.click()
assert.equal(sent.length, before + 3)
assert.deepEqual(sent.at(-1), feedback)
setPrState((prev) => ({
...prev,
checks: summarize([
{ name: "Typecheck", status: "failure", url: "https://github.com/example/repo/actions/runs/100/job/201" },
]),
}))
assert.equal(fix()?.disabled, false)
setPrState((prev) => ({ ...prev, checks: summarize([{ name: "Tests", status: "success" }]) }))
assert.equal(fix(), null)
cleanup()
36 changes: 36 additions & 0 deletions packages/kilo-vscode/tests/unit/am-pr-utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
parseConversation,
parseReviewers,
signature,
summarize,
} from "../../src/agent-manager/pr/am-pr-utils"
import type {
GhThread,
Expand Down Expand Up @@ -789,4 +790,39 @@ describe("signature with conversation", () => {
expect(withConvo).not.toBe(withoutConvo)
expect(updatedConvo).not.toBe(withConvo)
})

it("updates check links and failures even when aggregate counts stay the same", () => {
const base: PRStatus = {
number: 1,
title: "PR",
url: "https://example.com/pr/1",
state: "open",
review: null,
checks: summarize([
{ name: "Lint", status: "failure", url: "https://example.com/job/1" },
{ name: "Tests", status: "success" },
]),
reviewers: [],
additions: 0,
deletions: 0,
files: 0,
}
const rerun = {
...base,
checks: summarize([
{ name: "Lint", status: "failure", url: "https://example.com/job/2" },
{ name: "Tests", status: "success" },
]),
}
const swapped = {
...base,
checks: summarize([
{ name: "Lint", status: "success", url: "https://example.com/job/1" },
{ name: "Tests", status: "failure" },
]),
}
expect(signature(rerun)).not.toBe(signature(base))
expect(signature(swapped)).not.toBe(signature(base))
expect(signature(structuredClone(base))).toBe(signature(base))
})
})
104 changes: 104 additions & 0 deletions packages/kilo-vscode/tests/unit/pr-check-feedback.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { describe, expect, it } from "bun:test"
import { checkFeedback } from "../../webview-ui/agent-manager/pr/pr-check-feedback"
import type { PRCheck } from "../../webview-ui/agent-manager/pr/pr-types"

function feedback(checks: PRCheck[], url = "https://github.com/owner/repo/pull/42") {
return checkFeedback(
{
number: 42,
url,
checks: { status: "failure", total: checks.length, passed: 0, failed: 0, pending: 0, checks },
},
"CI feedback",
)
}

const failed: PRCheck = {
name: "Typecheck",
status: "failure",
url: "https://github.com/owner/repo/actions/runs/123/job/456",
}

describe("CI check feedback", () => {
it("sends only failed and cancelled checks with exact lazy log commands", () => {
const item = feedback([
failed,
{ name: "Timed out", status: "cancelled" },
{ name: "Passed tests", status: "success" },
{ name: "Running lint", status: "pending" },
{ name: "Skipped deploy", status: "skipped" },
])!
expect(item.origin).toBe("ci")
expect(item.id).toBe("ci:github.com/owner/repo:42")
expect(item.body).toContain('"Typecheck": failure')
expect(item.body).toContain('"Timed out": cancelled')
expect(item.body).not.toContain("Passed tests")
expect(item.body).not.toContain("Running lint")
expect(item.body).not.toContain("Skipped deploy")
expect(item.body).toContain("gh run view 123 --repo github.com/owner/repo --job 456 --log-failed")
expect(item.body).toContain('> "$log" 2>&1')
expect(item.body).toContain("40 lines / 4 KB")
expect(item.body).toContain("at most 3 excerpts")
})

it("offers no feedback for successful, skipped, pending or empty checks", () => {
for (const status of ["success", "skipped", "pending"] as const) {
expect(feedback([{ ...failed, status }])).toBeUndefined()
}
expect(feedback([])).toBeUndefined()
})

it("supports run-only links and explicit rerun attempts on enterprise hosts", () => {
const item = feedback(
[
{ ...failed, url: "https://git.example.com/owner/fork/actions/runs/123/attempts/2/job/789" },
{ ...failed, url: "https://git.example.com/owner/repo/actions/runs/456" },
],
"https://git.example.com/owner/repo/pull/42",
)!
expect(item.body).toContain("gh run view 123 --repo git.example.com/owner/fork --attempt 2 --job 789 --log-failed")
expect(item.body).toContain("gh run view 456 --repo git.example.com/owner/repo --log-failed")
})

it("keeps external CI links without inventing GitHub log commands", () => {
const item = feedback([
{ ...failed, url: "https://ci.example.com/build/123" },
{ ...failed, url: undefined },
])!
expect(item.body).toContain("https://ci.example.com/build/123")
expect(item.body).toContain("no GitHub Actions log command available")
expect(item.body).not.toContain("gh run view")
})

it.each([
"javascript:alert(1)",
"https://user:password@github.com/owner/repo/actions/runs/123/job/456",
"https://github.com/owner/repo/actions/runs/123/job/456/extra",
"https://evil.example/owner/repo/actions/runs/123/job/456",
"https://github.com/owner/repo;touch%20bad/actions/runs/123/job/456",
])("does not build commands from unsafe or unsupported links: %s", (url) => {
const item = feedback([{ ...failed, url }])!
expect(item.body).not.toContain("gh run view")
expect(item.body).not.toContain("password")
expect(item.body).not.toContain("javascript:")
})

it("bounds large check sets and names while keeping retrieval instructions", () => {
const item = feedback(
Array.from({ length: 200 }, (_, i) => ({
...failed,
name: `${i}: ${"long check name ".repeat(1_000)}`,
})),
)!
expect(item.body.length).toBeLessThan(4_500)
expect(item.body).toContain("195 more checks omitted")
expect(item.body).toContain("Inspect the saved check list in small batches")
expect(item.body).toContain("Never print or attach full logs")
})

it("identifies a single failure in a bounded card title", () => {
expect(feedback([failed])?.title).toBe("CI feedback: Typecheck")
expect(feedback([{ ...failed, name: "x".repeat(10_000) }])!.title.length).toBeLessThan(256)
expect(feedback([failed])!.body.length).toBeLessThan(1_500)
})
})
36 changes: 36 additions & 0 deletions packages/kilo-vscode/tests/unit/review-comments-pr.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
partReview,
parseReview,
reviewMetadata,
type CIReviewCommentData,
type PRReviewCommentData,
type ReviewCommentData,
} from "../../src/shared/review-comments"
Expand Down Expand Up @@ -41,6 +42,16 @@ function local(): ReviewCommentData {
return { id: "c1", file: "src/a.ts", side: "additions", line: 3, comment: "rename", selectedText: "const x = 1" }
}

function ci(overrides: Partial<CIReviewCommentData> = {}): CIReviewCommentData {
return {
id: "ci:42:100",
origin: "ci",
title: "Typecheck failed",
body: "Inspect the failed typecheck job before making a fix.",
...overrides,
}
}

function thread(overrides: Partial<PRComment> = {}): PRComment {
return {
id: "PRRC_1",
Expand Down Expand Up @@ -154,6 +165,31 @@ describe("PR review comment metadata", () => {
})
})

describe("CI review comment metadata", () => {
it("round-trips CI metadata with and without a visible message body", () => {
const data = { version: 1 as const, comments: [ci({ body: "Failed: `typecheck`\n\nRead logs on demand." })] }
const text = formatReviewCommentsMarkdown(data.comments)
const metadata = JSON.parse(JSON.stringify(reviewMetadata(data)))
expect(partReview(metadata, text)).toEqual({ data, body: "" })
expect(partReview(metadata, `${text}\n\nFix only these failures.`)).toEqual({
data,
body: "Fix only these failures.",
})
})

it("round-trips mixed local, PR, and CI metadata", () => {
const data = { version: 1 as const, comments: [local(), pr(), ci()] }
const text = formatReviewCommentsMarkdown(data.comments)
const metadata = JSON.parse(JSON.stringify(reviewMetadata(data)))
expect(partReview(metadata, text)).toEqual({ data, body: "" })
})

it("rejects an oversized CI body", () => {
const comments = [ci({ body: "x".repeat(16_001) })]
expect(parseReview({ version: 1, comments }, formatReviewCommentsMarkdown(comments))).toBeUndefined()
})
})

describe("prPayload", () => {
it("keys the payload by thread so a repeat send replaces the chip", () => {
expect(prPayload(thread()).id).toBe("PRRT_1")
Expand Down
3 changes: 3 additions & 0 deletions packages/kilo-vscode/webview-ui/agent-manager/i18n/ar.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions packages/kilo-vscode/webview-ui/agent-manager/i18n/br.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions packages/kilo-vscode/webview-ui/agent-manager/i18n/bs.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading