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/pr-conversation-timeline.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"kilo-code": minor
---

Show commits, force pushes, merges, approvals, and the pull request description in the Agent Manager PR conversation, in the order they happened.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
8 changes: 8 additions & 0 deletions packages/kilo-ui/src/components/icon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,14 @@ const icons: Record<string, { path: string; viewBox: string }> = {
// Stroked at 1.25 on the 20-unit grid (1px at 16px) to match the other outline icons.
path: `<circle cx="4.6875" cy="4.0625" r="1.875" stroke="currentColor" stroke-width="1.25"/><circle cx="4.6875" cy="15.9375" r="1.875" stroke="currentColor" stroke-width="1.25"/><circle cx="15.9375" cy="15.9375" r="1.875" stroke="currentColor" stroke-width="1.25"/><path d="M4.6875 5.9375V14.0625M15.9375 14.0625V6.25A2.5 2.5 0 0 0 13.4375 3.75H9.6875M11.5625 1.875L9.6875 3.75L11.5625 5.625" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>`,
},
"git-commit": {
viewBox: "0 0 20 20",
path: `<circle cx="10" cy="10" r="2.5" stroke="currentColor" stroke-width="1.25"/><path d="M1.875 10H7.5M12.5 10H18.125" stroke="currentColor" stroke-width="1.25" stroke-linecap="square"/>`,
},
"git-merge": {
viewBox: "0 0 20 20",
path: `<circle cx="5" cy="5" r="2.5" stroke="currentColor" stroke-width="1.25"/><circle cx="15" cy="15" r="2.5" stroke="currentColor" stroke-width="1.25"/><path d="M5 17.5V7.5A7.5 7.5 0 0 0 12.5 15" stroke="currentColor" stroke-width="1.25" stroke-linecap="square"/>`,
},
refresh: {
viewBox: "0 0 20 20",
path: `<path d="M17.0837 10.0003C17.0837 13.9123 13.9123 17.0837 10.0003 17.0837C6.08833 17.0837 2.91699 13.9123 2.91699 10.0003C2.91699 6.08833 6.08833 2.91699 10.0003 2.91699C12.3717 2.91699 14.4722 4.07428 15.7698 5.83366M15.7698 5.83366V2.91699M15.7698 5.83366H12.8532" stroke="currentColor" stroke-linecap="square"/>`,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1847,7 +1847,6 @@ export class AgentManagerProvider implements Disposable {
(...args) => this.log(...args),
)
}

public postMessage(message: unknown): void {
this.panel?.postMessage(message)
}
Expand Down
74 changes: 29 additions & 45 deletions packages/kilo-vscode/src/agent-manager/PRStatusPoller.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { ExecFileOptionsWithStringEncoding } from "child_process"
import { existsSync } from "fs"
import type { Worktree } from "./WorktreeStateManager"
import type { PRStatus, PRCheck, PRReviewer, PRConversationComment } from "./types"
import type { PRStatus, PRCheck, PRReviewer, PRTimelineItem } from "./types"
import { execWithShellEnv } from "./shell-env"
import { execGhRead } from "./gh"
import { classifyPRError } from "./git-import"
Expand All @@ -12,18 +12,11 @@ import {
signature,
formatCheckDuration,
parseComments,
parseConversation,
parseReviewers,
summarize,
} from "./pr/am-pr-utils"
import type {
PRResult,
GhThread,
GhReviewRequest,
GhReview,
GhConversationComment,
GhReviewWithBody,
} from "./pr/am-pr-types"
import { TIMELINE_QUERY, parseTimeline } from "./pr/timeline"
import type { PRResult, GhThread, GhReviewRequest, GhReview, GhTimelineItem } from "./pr/am-pr-types"
import { withContext } from "./pr/pr-comment-context"
import { oid } from "../shared/pr-comment-preview"

Expand Down Expand Up @@ -298,6 +291,8 @@ export class PRStatusPoller {
headRefOid: pr.headRefOid,
title: pr.title,
body: pr.body,
author: pr.author,
createdAt: pr.createdAt,
url: pr.url,
state: pr.state,
review: pr.review,
Expand Down Expand Up @@ -345,7 +340,7 @@ export class PRStatusPoller {
}

private static readonly BASE_JSON_FIELDS =
"id,number,title,body,url,state,isDraft,reviewDecision,additions,deletions,changedFiles,headRefName,baseRefOid,headRefOid"
"id,number,title,body,url,state,isDraft,reviewDecision,additions,deletions,changedFiles,headRefName,baseRefOid,headRefOid,author,createdAt"
private static readonly PR_JSON_FIELDS = `${PRStatusPoller.BASE_JSON_FIELDS},statusCheckRollup,reviewRequests,reviews`

/** Return a cached PR lookup if still fresh, otherwise fetch and cache.
Expand Down Expand Up @@ -512,7 +507,13 @@ export class PRStatusPoller {
): Promise<
| Pick<
PRStatus,
"comments" | "unresolvedThreads" | "conversation" | "baseRefOid" | "headRefOid" | "viewerDidAuthor"
| "comments"
| "unresolvedThreads"
| "conversation"
| "conversationHasEarlier"
| "baseRefOid"
| "headRefOid"
| "viewerDidAuthor"
>
| undefined
> {
Expand Down Expand Up @@ -548,36 +549,16 @@ export class PRStatusPoller {
}
}`
: ""
let extra = full
? `comments(last: 50) {
nodes {
id
author { login avatarUrl __typename }
body
createdAt
url
reactionGroups { content reactors { totalCount } viewerHasReacted }
viewerDidAuthor viewerCanUpdate viewerCanDelete
}
}
reviews(last: 50) {
nodes {
id
author { login avatarUrl __typename }
body
state
submittedAt
url
reactionGroups { content reactors { totalCount } viewerHasReacted }
}
}`
: ""
// Keep the timeline in the first review-thread request. This avoids a
// second GitHub round trip while leaving non-active worktree polls cheap.
let extra = full ? TIMELINE_QUERY : ""
const nodes: GhThread[] = []
const cursors = new Set<string>()
const ids = new Set<string>()
let total: number | undefined
let cursor: string | undefined
let conversation: PRConversationComment[] | undefined
let conversation: PRTimelineItem[] | undefined
let conversationHasEarlier: boolean | undefined
while (true) {
const query = `query($owner: String!, $repo: String!, $number: Int!, $cursor: String) {
repository(owner: $owner, name: $repo) {
Expand Down Expand Up @@ -626,7 +607,9 @@ export class PRStatusPoller {
}
nodes.push(...page.nodes)
if (extra) {
conversation = parseConversationPayload(stdout)
const parsed = parseConversationPayload(stdout)
conversation = parsed.items
conversationHasEarlier = parsed.hasEarlier
extra = ""
}
if (nodes.length > total) throw new Error("Incomplete PR review threads")
Expand All @@ -647,6 +630,7 @@ export class PRStatusPoller {
unresolvedThreads: unresolved,
comments: { total, unresolved, comments },
conversation,
conversationHasEarlier,
}
}
cursor = advance(page.pageInfo.endCursor, cursors)
Expand Down Expand Up @@ -716,11 +700,11 @@ async function settled<T>(thunks: (() => Promise<T>)[], concurrency: number): Pr
return results
}

function parseConversationPayload(stdout: string): PRConversationComment[] | undefined {
const pr = JSON.parse(stdout)?.data?.repository?.pullRequest
if (!pr) return undefined
return parseConversation(
(pr.comments?.nodes ?? []) as GhConversationComment[],
(pr.reviews?.nodes ?? []) as GhReviewWithBody[],
)
function parseConversationPayload(stdout: string): { items?: PRTimelineItem[]; hasEarlier: boolean } {
const page = JSON.parse(stdout)?.data?.repository?.pullRequest?.timelineItems
if (!page || !Array.isArray(page.nodes)) return { hasEarlier: false }
return {
items: parseTimeline(page.nodes as Array<GhTimelineItem | null>),
hasEarlier: page.pageInfo?.hasPreviousPage === true,
}
}
40 changes: 40 additions & 0 deletions packages/kilo-vscode/src/agent-manager/pr/am-pr-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,13 +73,53 @@ export interface GhReviewWithBody {
reactionGroups?: GhReactionGroup[]
}

export interface GhCommitAuthor {
user?: GhAuthor
name?: string
}

export interface GhCommit {
oid?: string
abbreviatedOid?: string
messageHeadline?: string
committedDate?: string
url?: string
author?: GhCommitAuthor
}

/**
* One node of `PullRequest.timelineItems`. Fields are a union of the selected
* inline fragments, so only `__typename` plus the matching fields are set.
*/
export interface GhTimelineItem {
__typename?: string
id?: string
author?: GhAuthor & { __typename?: string }
body?: string
createdAt?: string
url?: string
reactionGroups?: GhReactionGroup[]
viewerDidAuthor?: boolean
viewerCanUpdate?: boolean
viewerCanDelete?: boolean
state?: string
submittedAt?: string
commit?: GhCommit
actor?: GhAuthor
mergeRefName?: string
beforeCommit?: { abbreviatedOid?: string }
afterCommit?: { abbreviatedOid?: string }
}

export interface PRResult {
id?: string
number: number
baseRefOid?: string
headRefOid?: string
title: string
body: string
author?: string
createdAt?: string
url: string
state: PRState
review: ReviewDecision | null
Expand Down
86 changes: 43 additions & 43 deletions packages/kilo-vscode/src/agent-manager/pr/am-pr-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,10 @@ import type {
PRReactionContent,
PRReviewer,
PRStatus,
ReviewDecision,
ReviewerState,
} from "../types"
import { PR_REACTION_CONTENT } from "../../../webview-ui/agent-manager/pr/pr-types"
import { PR_REACTION_CONTENT, isConversationComment } from "../../../webview-ui/agent-manager/pr/pr-types"
import type {
PRResult,
GhAuthor,
Expand All @@ -29,22 +30,16 @@ export function parsePRResult(json: string): PRResult | null {
const data = JSON.parse(json)
if (!data.number) return null
const state = data.isDraft ? "draft" : (data.state?.toLowerCase() ?? "open")
const decision = data.reviewDecision as string | undefined
const review =
decision === "APPROVED"
? "approved"
: decision === "CHANGES_REQUESTED"
? "changes_requested"
: decision === "REVIEW_REQUIRED"
? "pending"
: null
const review = reviewValue(data.reviewDecision)
const result: PRResult = {
id: data.id,
number: data.number,
...(typeof data.baseRefOid === "string" ? { baseRefOid: data.baseRefOid } : {}),
...(typeof data.headRefOid === "string" ? { headRefOid: data.headRefOid } : {}),
title: data.title ?? "",
body: data.body ?? "",
...(typeof data.author?.login === "string" ? { author: data.author.login } : {}),
...(typeof data.createdAt === "string" ? { createdAt: data.createdAt } : {}),
url: data.url ?? "",
state,
review,
Expand All @@ -59,6 +54,13 @@ export function parsePRResult(json: string): PRResult | null {
return result
}

function reviewValue(value: unknown): ReviewDecision | null {
if (value === "APPROVED") return "approved"
if (value === "CHANGES_REQUESTED") return "changes_requested"
if (value === "REVIEW_REQUIRED") return "pending"
return null
}

function checks(items: unknown[]): PRStatus["checks"] {
const latest = new Map<string, { item: unknown; index: number; started: number }>()
items.forEach((item, index) => {
Expand Down Expand Up @@ -278,7 +280,7 @@ function bot(author?: GhAuthor & { __typename?: string }): boolean {
return author.__typename === "Bot" || author.login.endsWith("[bot]") || author.login === "kilo-code-bot"
}

function commentItem(node: GhConversationComment): PRConversationComment | null {
export function commentItem(node: GhConversationComment): PRConversationComment | null {
if (!node.id || !node.body?.trim()) return null
const reactions = parseReactions(node.reactionGroups)
return {
Expand All @@ -296,8 +298,12 @@ function commentItem(node: GhConversationComment): PRConversationComment | null
}
}

function reviewItem(node: GhReviewWithBody): PRConversationComment | null {
if (!node.id || !node.body?.trim()) return null
export function reviewItem(node: GhReviewWithBody): PRConversationComment | null {
// A review without text is still an event: an approval or a change request
// has to show in the conversation even when the reviewer wrote nothing.
if (!node.id) return null
const state = REVIEWER_STATE[node.state ?? ""]
if (!node.body?.trim() && !state) return null
const reactions = parseReactions(node.reactionGroups)
return {
id: node.id,
Expand All @@ -306,32 +312,15 @@ function reviewItem(node: GhReviewWithBody): PRConversationComment | null {
canDelete: false,
author: node.author?.login ?? "unknown",
avatar: node.author?.avatarUrl,
body: node.body,
body: node.body ?? "",
createdAt: node.submittedAt ? new Date(node.submittedAt).getTime() : undefined,
url: node.url,
state: REVIEWER_STATE[node.state ?? ""],
state,
isBot: bot(node.author) || undefined,
...(reactions.length > 0 ? { reactions } : {}),
}
}

export function parseConversation(
comments: GhConversationComment[],
reviews: GhReviewWithBody[],
): PRConversationComment[] {
const items: PRConversationComment[] = []
for (const node of comments) {
const item = commentItem(node)
if (item) items.push(item)
}
for (const node of reviews) {
const item = reviewItem(node)
if (item) items.push(item)
}
items.sort((a, b) => (a.createdAt ?? 0) - (b.createdAt ?? 0))
return items
}

/**
* Short, user-facing reason from a failed `gh` invocation. The raw message
* repeats the whole command line, which is useless inside a comment card.
Expand Down Expand Up @@ -360,6 +349,7 @@ export function mergePRStatus(prev: PRStatus | undefined, next: PRStatus): PRSta
comments: next.comments ?? current?.comments,
unresolvedThreads: next.unresolvedThreads ?? next.comments?.unresolved ?? current?.unresolvedThreads,
conversation: next.conversation ?? prev.conversation,
conversationHasEarlier: next.conversationHasEarlier ?? prev.conversationHasEarlier,
}
}

Expand Down Expand Up @@ -388,17 +378,27 @@ export function signature(pr: PRStatus): string {
pr.unresolvedThreads ?? null,
commentsSig(pr.comments?.comments),
],
pr.conversation?.map((c) => [
c.id,
c.author,
c.body,
c.state ?? "",
c.isBot ? 1 : 0,
c.reactions?.map((reaction) => [reaction.content, reaction.count, reaction.viewerHasReacted]) ?? [],
c.kind,
c.canEdit,
c.canDelete,
]) ?? [],
pr.conversation?.map((item) =>
isConversationComment(item)
? [
item.id,
item.author,
item.body,
item.state ?? "",
item.isBot ? 1 : 0,
item.reactions?.map((reaction) => [reaction.content, reaction.count, reaction.viewerHasReacted]) ?? [],
item.kind,
item.canEdit,
item.canDelete,
]
: [
item.kind,
item.id,
item.createdAt ?? null,
item.kind === "commit" ? item.sha : item.event,
item.kind === "event" ? (item.detail ?? "") : "",
],
) ?? [],
])
}

Expand Down
Loading
Loading