diff --git a/.changeset/giant-buckets-clap.md b/.changeset/giant-buckets-clap.md new file mode 100644 index 00000000000..1080d014389 --- /dev/null +++ b/.changeset/giant-buckets-clap.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Introduces AI contribution tracking so users can better understand agentic coding impact diff --git a/src/core/tools/ApplyDiffTool.ts b/src/core/tools/ApplyDiffTool.ts index 7161c7c08ef..0c33708c472 100644 --- a/src/core/tools/ApplyDiffTool.ts +++ b/src/core/tools/ApplyDiffTool.ts @@ -15,6 +15,7 @@ import { EXPERIMENT_IDS, experiments } from "../../shared/experiments" import { computeDiffStats, sanitizeUnifiedDiff } from "../diff/stats" import { BaseTool, ToolCallbacks } from "./BaseTool" import type { ToolUse } from "../../shared/tools" +import { trackContribution } from "../../services/contribution-tracking/ContributionTrackingService" // kilocode_change interface ApplyDiffParams { path: string @@ -175,6 +176,19 @@ export class ApplyDiffTool extends BaseTool<"apply_diff"> { const didApprove = await askApproval("tool", completeMessage, toolProgressStatus, isWriteProtected) + // kilocode_change start + // Track contribution (fire-and-forget, never blocks user workflow) + trackContribution({ + cwd: task.cwd, + filePath: relPath, + unifiedDiff: unifiedPatch, + status: didApprove ? "accepted" : "rejected", + taskId: task.taskId, + organizationId: state?.apiConfiguration?.kilocodeOrganizationId, + kilocodeToken: state?.apiConfiguration?.kilocodeToken || "", + }) + // kilocode_change end + if (!didApprove) { return } @@ -219,6 +233,19 @@ export class ApplyDiffTool extends BaseTool<"apply_diff"> { const didApprove = await askApproval("tool", completeMessage, toolProgressStatus, isWriteProtected) + // kilocode_change start + // Track contribution (fire-and-forget, never blocks user workflow) + trackContribution({ + cwd: task.cwd, + filePath: relPath, + unifiedDiff: unifiedPatch, + status: didApprove ? "accepted" : "rejected", + taskId: task.taskId, + organizationId: state?.apiConfiguration?.kilocodeOrganizationId, + kilocodeToken: state?.apiConfiguration?.kilocodeToken || "", + }) + // kilocode_change end + if (!didApprove) { await task.diffViewProvider.revertChanges() task.processQueuedMessages() diff --git a/src/core/tools/MultiApplyDiffTool.ts b/src/core/tools/MultiApplyDiffTool.ts index 43833f00685..60a42cf0ccd 100644 --- a/src/core/tools/MultiApplyDiffTool.ts +++ b/src/core/tools/MultiApplyDiffTool.ts @@ -18,6 +18,7 @@ import { applyDiffTool as applyDiffToolClass } from "./ApplyDiffTool" import { computeDiffStats, sanitizeUnifiedDiff } from "../diff/stats" import { isNativeProtocol } from "@roo-code/types" import { resolveToolProtocol } from "../../utils/resolveToolProtocol" +import { trackContribution } from "../../services/contribution-tracking/ContributionTrackingService" // kilocode_change export interface DiffOperation { path: string @@ -638,6 +639,19 @@ ${errorDetails ? `\nTechnical details:\n${errorDetails}\n` : ""} const isWriteProtected = cline.rooProtectedController?.isWriteProtected(relPath) || false didApprove = await askApproval("tool", operationMessage, toolProgressStatus, isWriteProtected) + // kilocode_change start + // Track contribution for single file operation (fire-and-forget) + trackContribution({ + cwd: cline.cwd, + filePath: relPath, + unifiedDiff: unifiedPatch, + status: didApprove ? "accepted" : "rejected", + taskId: cline.taskId, + organizationId: state?.apiConfiguration?.kilocodeOrganizationId, + kilocodeToken: state?.apiConfiguration?.kilocodeToken || "", + }) + // kilocode_change end + if (!didApprove) { // Revert changes if diff view was shown if (!isPreventFocusDisruptionEnabled) { @@ -663,6 +677,21 @@ ${errorDetails ? `\nTechnical details:\n${errorDetails}\n` : ""} } } else { // Batch operations - already approved above + // kilocode_change start + // Track contribution for batch file operation (fire-and-forget) + const unifiedPatchRaw = formatResponse.createPrettyPatch(relPath, beforeContent!, originalContent!) + const unifiedPatch = sanitizeUnifiedDiff(unifiedPatchRaw) + trackContribution({ + cwd: cline.cwd, + filePath: relPath, + unifiedDiff: unifiedPatch, + status: "accepted", // Batch operations are already approved at this point + taskId: cline.taskId, + organizationId: state?.apiConfiguration?.kilocodeOrganizationId, + kilocodeToken: state?.apiConfiguration?.kilocodeToken || "", + }) + // kilocode_change end + if (isPreventFocusDisruptionEnabled) { // Direct file write without diff view or opening the file cline.diffViewProvider.editType = "modify" diff --git a/src/core/tools/WriteToFileTool.ts b/src/core/tools/WriteToFileTool.ts index 8e3897744a6..c7a06fc1e62 100644 --- a/src/core/tools/WriteToFileTool.ts +++ b/src/core/tools/WriteToFileTool.ts @@ -17,6 +17,7 @@ import { EXPERIMENT_IDS, experiments } from "../../shared/experiments" import { convertNewFileToUnifiedDiff, computeDiffStats, sanitizeUnifiedDiff } from "../diff/stats" import { BaseTool, ToolCallbacks } from "./BaseTool" import type { ToolUse } from "../../shared/tools" +import { trackContribution } from "../../services/contribution-tracking/ContributionTrackingService" // kilocode_change interface WriteToFileParams { path: string @@ -142,6 +143,19 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { const didApprove = await askApproval("tool", completeMessage, undefined, isWriteProtected) + // kilocode_change start + // Track contribution (fire-and-forget, never blocks user workflow) + trackContribution({ + cwd: task.cwd, + filePath: relPath, + unifiedDiff: unified, + status: didApprove ? "accepted" : "rejected", + taskId: task.taskId, + organizationId: state?.apiConfiguration?.kilocodeOrganizationId, + kilocodeToken: state?.apiConfiguration?.kilocodeToken || "", + }) + // kilocode_change end + if (!didApprove) { return } @@ -174,6 +188,19 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { const didApprove = await askApproval("tool", completeMessage, undefined, isWriteProtected) + // kilocode_change start + // Track contribution (fire-and-forget, never blocks user workflow) + trackContribution({ + cwd: task.cwd, + filePath: relPath, + unifiedDiff: unified, + status: didApprove ? "accepted" : "rejected", + taskId: task.taskId, + organizationId: state?.apiConfiguration?.kilocodeOrganizationId, + kilocodeToken: state?.apiConfiguration?.kilocodeToken || "", + }) + // kilocode_change end + if (!didApprove) { await task.diffViewProvider.revertChanges() return diff --git a/src/core/tools/kilocode/editFileTool.ts b/src/core/tools/kilocode/editFileTool.ts index 91bafc71eae..c550047347f 100644 --- a/src/core/tools/kilocode/editFileTool.ts +++ b/src/core/tools/kilocode/editFileTool.ts @@ -13,6 +13,8 @@ import { TelemetryService } from "@roo-code/telemetry" import { type ClineProviderState } from "../../webview/ClineProvider" import { ClineSayTool } from "../../../shared/ExtensionMessage" import { X_KILOCODE_ORGANIZATIONID, X_KILOCODE_TASKID, X_KILOCODE_TESTER } from "../../../shared/kilocode/headers" +import { trackContribution } from "../../../services/contribution-tracking/ContributionTrackingService" +import { sanitizeUnifiedDiff } from "../../diff/stats" const FAST_APPLY_MODEL_PRICING = { "morph-v3-fast": { @@ -179,6 +181,21 @@ export async function editFileTool( cline.rooProtectedController?.isWriteProtected(relPath) || false, ) + // Track contribution (fire-and-forget, never blocks user workflow) + const provider = cline.providerRef.deref() + const state = await provider?.getState() + const unifiedPatchRaw = formatResponse.createPrettyPatch(relPath, originalContent, newContent) + const unifiedPatch = sanitizeUnifiedDiff(unifiedPatchRaw) + trackContribution({ + cwd: cline.cwd, + filePath: relPath, + unifiedDiff: unifiedPatch, + status: approved ? "accepted" : "rejected", + taskId: cline.taskId, + organizationId: state?.apiConfiguration?.kilocodeOrganizationId, + kilocodeToken: state?.apiConfiguration?.kilocodeToken || "", + }) + if (!approved) { await cline.diffViewProvider.revertChanges() return diff --git a/src/services/contribution-tracking/ContributionTrackingService.ts b/src/services/contribution-tracking/ContributionTrackingService.ts new file mode 100644 index 00000000000..9e26720e01a --- /dev/null +++ b/src/services/contribution-tracking/ContributionTrackingService.ts @@ -0,0 +1,330 @@ +// kilocode_change - new file +import crypto from "crypto" +import { getKiloUrlFromToken } from "@roo-code/types" +import { TelemetryService } from "@roo-code/telemetry" +import { fetchWithRetries } from "../../shared/http" +import { getCurrentBranch } from "../code-index/managed/git-utils" +import { getProjectId } from "../../utils/kilo-config-file" +import { getGitRepositoryInfo } from "../../utils/git" +import { + type ContributionPayload, + type LineChange, + type TokenProvisionResponse, + TokenProvisionResponse as TokenProvisionResponseSchema, + type TrackContributionParams, +} from "./contribution-tracking-types" + +/** + * Service for tracking AI contributions to the attributions worker + * + * This service handles: + * - Short-lived JWT token management with caching + * - Line-level change tracking with SHA-1 hashing + * - Unified diff parsing + * - Fire-and-forget API calls to the attributions worker + */ +export class ContributionTrackingService { + private static instance: ContributionTrackingService + private cachedToken: TokenProvisionResponse | null = null + private tokenFetchPromise: Promise | null = null + + // AI Attribution service URL + private static readonly CONTRIBUTION_SERVICE_URL = "https://ai-attribution.kiloapps.io/attributions/track" + + // Refresh token 1 minute before expiry + private static readonly TOKEN_REFRESH_BUFFER_MS = 60 * 1000 + + private constructor() {} + + /** + * Get the singleton instance + */ + static getInstance(): ContributionTrackingService { + if (!ContributionTrackingService.instance) { + ContributionTrackingService.instance = new ContributionTrackingService() + } + return ContributionTrackingService.instance + } + + /** + * Clear cached token (useful for testing and logout scenarios) + */ + clearCachedToken(): void { + this.cachedToken = null + this.tokenFetchPromise = null + } + + /** + * Check if the cached token is still valid + * Returns false if token doesn't exist or is expired/about to expire + */ + private isTokenValid(organizationId: string): boolean { + if (!this.cachedToken) { + return false + } + + // Token must be for the same organization + if (this.cachedToken.organizationId !== organizationId) { + return false + } + + // Check if token is expired or about to expire (within refresh buffer) + // Derive the numeric timestamp from the ISO 8601 string at comparison time + const now = Date.now() + const expiresAtMs = new Date(this.cachedToken.expiresAt).getTime() + const expiresWithBuffer = expiresAtMs - ContributionTrackingService.TOKEN_REFRESH_BUFFER_MS + + return now < expiresWithBuffer + } + + /** + * Fetch a new short-lived token from the Kilo backend + * @param organizationId - The organization ID to get a token for + * @param kilocodeToken - The main Kilocode authentication token + * @returns The token provision response + */ + private async fetchToken(organizationId: string, kilocodeToken: string): Promise { + try { + const url = getKiloUrlFromToken( + `https://api.kilo.ai/api/organizations/${organizationId}/user-tokens`, + kilocodeToken, + ) + + const response = await fetchWithRetries({ + url, + method: "POST", + headers: { + Authorization: `Bearer ${kilocodeToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({}), // Empty body as per spec + }) + + if (!response.ok) { + throw new Error(`Failed to fetch token: ${response.statusText}`) + } + + // Store the canonical response directly without transformation + this.cachedToken = TokenProvisionResponseSchema.parse(await response.json()) + + return this.cachedToken + } catch (error) { + console.error("[ContributionTracking] Failed to fetch token:", error) + throw error + } + } + + /** + * Get a valid token, fetching a new one if necessary + * Handles caching and concurrent requests + */ + private async getValidToken(organizationId: string, kilocodeToken: string): Promise { + // If we have a valid cached token, return it + if (this.isTokenValid(organizationId)) { + return this.cachedToken! + } + + // If a fetch is already in progress, wait for it + if (this.tokenFetchPromise) { + return this.tokenFetchPromise + } + + // Start a new fetch + this.tokenFetchPromise = this.fetchToken(organizationId, kilocodeToken) + + try { + const token = await this.tokenFetchPromise + return token + } finally { + // Clear the promise so future calls can fetch again if needed + this.tokenFetchPromise = null + } + } + + /** + * Compute SHA-1 hash of line content + * Normalizes line endings for consistent hashing + */ + private computeLineHash(lineContent: string): string { + // Remove line endings for consistent hashing across platforms + const normalized = lineContent.replace(/\r?\n$/, "") + return crypto.createHash("sha1").update(normalized, "utf8").digest("hex") + } + + /** + * Extract line changes from unified diff + * Returns arrays of added and removed lines with their hashes + * + * @param unifiedDiff - The unified diff string + * @returns Object containing arrays of added and removed line changes + */ + private extractLineChanges(unifiedDiff: string): { + linesAdded: LineChange[] + linesRemoved: LineChange[] + } { + const linesAdded: LineChange[] = [] + const linesRemoved: LineChange[] = [] + + const lines = unifiedDiff.split("\n") + let currentLine = 0 + + for (const line of lines) { + if (line.startsWith("@@")) { + // Parse hunk header to get line numbers + // Format: @@ -oldStart,oldCount +newStart,newCount @@ + const match = line.match(/@@ -(\d+),?\d* \+(\d+),?\d* @@/) + if (match) { + currentLine = parseInt(match[2], 10) // New file line number + } + } else if (line.startsWith("+") && !line.startsWith("+++")) { + // Added line (skip +++ file markers) + const content = line.substring(1) + linesAdded.push({ + line_number: currentLine++, + line_hash: this.computeLineHash(content), + }) + } else if (line.startsWith("-") && !line.startsWith("---")) { + // Removed line (skip --- file markers) + const content = line.substring(1) + linesRemoved.push({ + line_number: currentLine, + line_hash: this.computeLineHash(content), + }) + } else if (!line.startsWith("\\")) { + // Context line (unchanged) - increment line counter + // Skip lines starting with \ (e.g., "\ No newline at end of file") + currentLine++ + } + } + + return { linesAdded, linesRemoved } + } + + /** + * Track a file edit contribution + * This is the main public method that should be called when a user accepts or rejects a file edit + * + * @param params - Parameters for tracking the contribution + * + * @example + * ```typescript + * const service = ContributionTrackingService.getInstance() + * await service.trackContribution({ + * cwd: '/path/to/repo', + * filePath: 'src/file.ts', + * unifiedDiff: '...', + * status: 'accepted', + * taskId: 'task_123', + * organizationId: 'org_456', + * kilocodeToken: 'token_789' + * }) + * ``` + */ + async trackContribution(params: TrackContributionParams): Promise { + try { + // Skip tracking if telemetry is disabled (respects user's privacy preferences) + if (TelemetryService.hasInstance() && !TelemetryService.instance.isTelemetryEnabled()) { + return + } + + // Skip tracking if no organization ID + if (!params.organizationId) { + return + } + + // Get git context (branch, repository URL, and project ID) + const [branch, gitInfo] = await Promise.all([ + getCurrentBranch(params.cwd), + getGitRepositoryInfo(params.cwd), + ]) + + // Get project ID with git repository URL as fallback + const projectId = await getProjectId(params.cwd, gitInfo.repositoryUrl) + + if (!projectId) { + return + } + + // Extract line changes from the unified diff + const { linesAdded, linesRemoved } = this.extractLineChanges(params.unifiedDiff) + + // Get a valid token for the attributions service + const cachedToken = await this.getValidToken(params.organizationId, params.kilocodeToken) + + // Build the payload with snake_case field names + const payload: ContributionPayload = { + project_id: projectId || "unknown", + branch: branch || "unknown", + file_path: params.filePath, + lines_added: linesAdded, + lines_removed: linesRemoved, + status: params.status, + task_id: params.taskId, + } + + // Send to the attributions worker + // Fire-and-forget: don't block user workflow if this fails + await this.sendToAttributionsWorker(payload, cachedToken.token) + } catch (error) { + // Log error but don't throw - tracking should never block user workflow + console.error("[ContributionTracking] Failed to track contribution:", error) + } + } + + /** + * Send contribution data to the attributions worker + * @param payload - The contribution payload + * @param token - The short-lived JWT token + */ + private async sendToAttributionsWorker(payload: ContributionPayload, token: string): Promise { + try { + const response = await fetchWithRetries({ + url: ContributionTrackingService.CONTRIBUTION_SERVICE_URL, + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(payload), + }) + + if (!response.ok) { + throw new Error(`Failed to track contribution: ${response.statusText}`) + } + } catch (error) { + console.error("[ContributionTracking] Failed to send to attributions worker:", error) + throw error + } + } +} + +/** + * Track a contribution (fire-and-forget) + * + * This is a convenience function that handles getting the service instance + * and catching/logging any errors. Callsites can simply fire and forget + * without needing to handle errors themselves. + * + * @param params - Parameters for tracking the contribution + * + * @example + * ```typescript + * // Simple fire-and-forget usage + * trackContribution({ + * cwd: task.cwd, + * filePath: relPath, + * unifiedDiff: unifiedPatch, + * status: didApprove ? "accepted" : "rejected", + * taskId: task.taskId, + * organizationId: state?.apiConfiguration?.kilocodeOrganizationId, + * kilocodeToken: state?.apiConfiguration?.kilocodeToken || "", + * }) + * ``` + */ +export function trackContribution(params: TrackContributionParams): void { + const service = ContributionTrackingService.getInstance() + service.trackContribution(params).catch((error: unknown) => { + // Errors are already logged in the service, this just prevents unhandled rejection + console.debug("[trackContribution] Contribution tracking failed:", error) + }) +} diff --git a/src/services/contribution-tracking/__tests__/ContributionTrackingService.spec.ts b/src/services/contribution-tracking/__tests__/ContributionTrackingService.spec.ts new file mode 100644 index 00000000000..4ebc8f1cc1b --- /dev/null +++ b/src/services/contribution-tracking/__tests__/ContributionTrackingService.spec.ts @@ -0,0 +1,349 @@ +// kilocode_change - new file +import { describe, it, expect, vi, beforeEach } from "vitest" +import { ContributionTrackingService } from "../ContributionTrackingService" +import type { TrackContributionParams } from "../contribution-tracking-types" + +// Mock dependencies +vi.mock("../../../shared/http") +vi.mock("../../code-index/managed/git-utils") +vi.mock("../../../utils/kilo-config-file") +vi.mock("../../../utils/git") + +describe("ContributionTrackingService", () => { + let service: ContributionTrackingService + + beforeEach(() => { + // Get fresh instance for each test + service = ContributionTrackingService.getInstance() + // Clear any cached token + service.clearCachedToken() + // Clear all mocks + vi.clearAllMocks() + }) + + describe("singleton pattern", () => { + it("should return the same instance", () => { + const instance1 = ContributionTrackingService.getInstance() + const instance2 = ContributionTrackingService.getInstance() + expect(instance1).toBe(instance2) + }) + }) + + describe("clearCachedToken", () => { + it("should clear the cached token", () => { + service.clearCachedToken() + // Token should be cleared - we can't directly test this but it shouldn't throw + expect(() => service.clearCachedToken()).not.toThrow() + }) + }) + + describe("line hashing", () => { + it("should compute consistent hash for same content", () => { + // Access private method via any cast for testing + const hash1 = (service as any).computeLineHash("const x = 1") + const hash2 = (service as any).computeLineHash("const x = 1") + expect(hash1).toBe(hash2) + expect(hash1).toHaveLength(40) // SHA-1 produces 40 character hex string + }) + + it("should normalize line endings", () => { + const hash1 = (service as any).computeLineHash("const x = 1\n") + const hash2 = (service as any).computeLineHash("const x = 1\r\n") + const hash3 = (service as any).computeLineHash("const x = 1") + expect(hash1).toBe(hash2) + expect(hash2).toBe(hash3) + }) + + it("should produce different hashes for different content", () => { + const hash1 = (service as any).computeLineHash("const x = 1") + const hash2 = (service as any).computeLineHash("const x = 2") + expect(hash1).not.toBe(hash2) + }) + }) + + describe("diff parsing", () => { + it("should extract added lines", () => { + const diff = `@@ -1,2 +1,3 @@ + const x = 1 ++const y = 2 + console.log(x)` + + const { linesAdded, linesRemoved } = (service as any).extractLineChanges(diff) + expect(linesAdded).toHaveLength(1) + expect(linesAdded[0].line_number).toBe(2) + expect(linesAdded[0].line_hash).toBeDefined() + expect(linesRemoved).toHaveLength(0) + }) + + it("should extract removed lines", () => { + const diff = `@@ -1,3 +1,2 @@ + const x = 1 +-const y = 2 + console.log(x)` + + const { linesAdded, linesRemoved } = (service as any).extractLineChanges(diff) + expect(linesAdded).toHaveLength(0) + expect(linesRemoved).toHaveLength(1) + expect(linesRemoved[0].line_number).toBe(2) + expect(linesRemoved[0].line_hash).toBeDefined() + }) + + it("should handle multiple hunks", () => { + const diff = `@@ -1,2 +1,3 @@ + const x = 1 ++const y = 2 + console.log(x) +@@ -10,2 +11,3 @@ + function test() { ++ return true + }` + + const { linesAdded, linesRemoved } = (service as any).extractLineChanges(diff) + expect(linesAdded).toHaveLength(2) + expect(linesAdded[0].line_number).toBe(2) + expect(linesAdded[1].line_number).toBe(12) + }) + + it("should skip file markers", () => { + const diff = `--- a/file.ts ++++ b/file.ts +@@ -1,2 +1,3 @@ + const x = 1 ++const y = 2 + console.log(x)` + + const { linesAdded, linesRemoved } = (service as any).extractLineChanges(diff) + expect(linesAdded).toHaveLength(1) + expect(linesRemoved).toHaveLength(0) + }) + + it("should handle empty diff", () => { + const diff = "" + const { linesAdded, linesRemoved } = (service as any).extractLineChanges(diff) + expect(linesAdded).toHaveLength(0) + expect(linesRemoved).toHaveLength(0) + }) + }) + + describe("token management", () => { + it("should cache token and reuse it", async () => { + const { fetchWithRetries } = await import("../../../shared/http") + const mockFetchWithRetries = vi.mocked(fetchWithRetries) + + const futureExpiry = new Date(Date.now() + 15 * 60 * 1000).toISOString() + + mockFetchWithRetries.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + token: "short-lived-token", + expiresAt: futureExpiry, + organizationId: "org-1", + }), + } as Response) + + // First call should fetch token + const token1 = await (service as any).getValidToken("org-1", "main-token") + expect(mockFetchWithRetries).toHaveBeenCalledTimes(1) + + // Second call should reuse cached token + const token2 = await (service as any).getValidToken("org-1", "main-token") + expect(mockFetchWithRetries).toHaveBeenCalledTimes(1) // Still 1, not 2 + expect(token1.token).toBe(token2.token) + }) + + it("should fetch new token for different organization", async () => { + const { fetchWithRetries } = await import("../../../shared/http") + const mockFetchWithRetries = vi.mocked(fetchWithRetries) + + const futureExpiry = new Date(Date.now() + 15 * 60 * 1000).toISOString() + + mockFetchWithRetries + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + token: "token-org-1", + expiresAt: futureExpiry, + organizationId: "org-1", + }), + } as Response) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + token: "token-org-2", + expiresAt: futureExpiry, + organizationId: "org-2", + }), + } as Response) + + // Fetch token for org-1 + const token1 = await (service as any).getValidToken("org-1", "main-token") + expect(token1.token).toBe("token-org-1") + + // Fetch token for org-2 (should not use cached token from org-1) + const token2 = await (service as any).getValidToken("org-2", "main-token") + expect(token2.token).toBe("token-org-2") + expect(mockFetchWithRetries).toHaveBeenCalledTimes(2) + }) + }) + + describe("trackContribution", () => { + it("should skip tracking when no organization ID", async () => { + const { fetchWithRetries } = await import("../../../shared/http") + const mockFetchWithRetries = vi.mocked(fetchWithRetries) + + const params: TrackContributionParams = { + cwd: "/test/repo", + filePath: "test.ts", + unifiedDiff: "@@ -1,1 +1,2 @@\n const x = 1\n+const y = 2", + status: "accepted", + kilocodeToken: "token", + // organizationId is missing + } + + await service.trackContribution(params) + + // Should not make any API calls + expect(mockFetchWithRetries).not.toHaveBeenCalled() + }) + + it("should skip tracking when no project ID", async () => { + const { fetchWithRetries } = await import("../../../shared/http") + const mockFetchWithRetries = vi.mocked(fetchWithRetries) + + const { getProjectId } = await import("../../../utils/kilo-config-file") + const mockGetProjectId = vi.mocked(getProjectId) + mockGetProjectId.mockResolvedValueOnce(undefined) + + const { getCurrentBranch } = await import("../../code-index/managed/git-utils") + const mockGetCurrentBranch = vi.mocked(getCurrentBranch) + mockGetCurrentBranch.mockResolvedValueOnce("main") + + const { getGitRepositoryInfo } = await import("../../../utils/git") + const mockGetGitRepositoryInfo = vi.mocked(getGitRepositoryInfo) + mockGetGitRepositoryInfo.mockResolvedValueOnce({ + repositoryUrl: "https://github.com/test/repo.git", + repositoryName: "test/repo", + defaultBranch: "main", + }) + + const params: TrackContributionParams = { + cwd: "/test/repo", + filePath: "test.ts", + unifiedDiff: "@@ -1,1 +1,2 @@\n const x = 1\n+const y = 2", + status: "accepted", + organizationId: "org-1", + kilocodeToken: "token", + } + + await service.trackContribution(params) + + // Should not make any API calls + expect(mockFetchWithRetries).not.toHaveBeenCalled() + }) + + it("should successfully track accepted contribution", async () => { + const { fetchWithRetries } = await import("../../../shared/http") + const mockFetchWithRetries = vi.mocked(fetchWithRetries) + + const { getProjectId } = await import("../../../utils/kilo-config-file") + const mockGetProjectId = vi.mocked(getProjectId) + mockGetProjectId.mockResolvedValueOnce("test-project") + + const { getCurrentBranch } = await import("../../code-index/managed/git-utils") + const mockGetCurrentBranch = vi.mocked(getCurrentBranch) + mockGetCurrentBranch.mockResolvedValueOnce("feature/test") + + const { getGitRepositoryInfo } = await import("../../../utils/git") + const mockGetGitRepositoryInfo = vi.mocked(getGitRepositoryInfo) + mockGetGitRepositoryInfo.mockResolvedValueOnce({ + repositoryUrl: "https://github.com/test/repo.git", + repositoryName: "test/repo", + defaultBranch: "main", + }) + + const futureExpiry = new Date(Date.now() + 15 * 60 * 1000).toISOString() + + // Mock token fetch + mockFetchWithRetries + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + token: "short-lived-token", + expiresAt: futureExpiry, + organizationId: "org-1", + }), + } as Response) + // Mock contribution tracking + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ success: true }), + } as Response) + + const params: TrackContributionParams = { + cwd: "/test/repo", + filePath: "test.ts", + unifiedDiff: "@@ -1,1 +1,2 @@\n const x = 1\n+const y = 2", + status: "accepted", + taskId: "task-123", + organizationId: "org-1", + kilocodeToken: "main-token", + } + + await service.trackContribution(params) + + // Should have made 2 API calls: token fetch + contribution tracking + expect(mockFetchWithRetries).toHaveBeenCalledTimes(2) + + // Verify contribution tracking call + const trackingCall = mockFetchWithRetries.mock.calls[1][0] + expect(trackingCall.method).toBe("POST") + expect(trackingCall.headers).toMatchObject({ + Authorization: "Bearer short-lived-token", + "Content-Type": "application/json", + }) + + const payload = JSON.parse(trackingCall.body as string) + expect(payload).toMatchObject({ + project_id: "test-project", + branch: "feature/test", + file_path: "test.ts", + status: "accepted", + task_id: "task-123", + }) + expect(payload.lines_added).toHaveLength(1) + expect(payload.lines_removed).toHaveLength(0) + }) + + it("should handle errors gracefully without throwing", async () => { + const { fetchWithRetries } = await import("../../../shared/http") + const mockFetchWithRetries = vi.mocked(fetchWithRetries) + + const { getCurrentBranch } = await import("../../code-index/managed/git-utils") + const mockGetCurrentBranch = vi.mocked(getCurrentBranch) + mockGetCurrentBranch.mockResolvedValueOnce("main") + + const { getGitRepositoryInfo } = await import("../../../utils/git") + const mockGetGitRepositoryInfo = vi.mocked(getGitRepositoryInfo) + mockGetGitRepositoryInfo.mockResolvedValueOnce({}) + + const { getProjectId } = await import("../../../utils/kilo-config-file") + const mockGetProjectId = vi.mocked(getProjectId) + mockGetProjectId.mockRejectedValueOnce(new Error("Git error")) + + const params: TrackContributionParams = { + cwd: "/test/repo", + filePath: "test.ts", + unifiedDiff: "@@ -1,1 +1,2 @@\n const x = 1\n+const y = 2", + status: "accepted", + organizationId: "org-1", + kilocodeToken: "token", + } + + // Should not throw + await expect(service.trackContribution(params)).resolves.not.toThrow() + + // Should not make tracking API call + expect(mockFetchWithRetries).not.toHaveBeenCalled() + }) + }) +}) diff --git a/src/services/contribution-tracking/contribution-tracking-types.ts b/src/services/contribution-tracking/contribution-tracking-types.ts new file mode 100644 index 00000000000..5cbbb0871a8 --- /dev/null +++ b/src/services/contribution-tracking/contribution-tracking-types.ts @@ -0,0 +1,48 @@ +// kilocode_change - new file +import { z } from "zod" + +/** + * Line change information with hash + * Uses snake_case to match the API contract + */ +export interface LineChange { + line_number: number + line_hash: string +} + +/** + * Contribution payload sent to the attributions worker + * Uses snake_case to match the API contract + */ +export interface ContributionPayload { + project_id: string + branch: string + file_path: string + lines_added: LineChange[] + lines_removed: LineChange[] + status: "accepted" | "rejected" + task_id?: string +} + +export type TokenProvisionResponse = z.infer +/** + * Zod schema for validating token provisioning response + */ +export const TokenProvisionResponse = z.object({ + token: z.string(), + expiresAt: z.string(), // ISO 8601 date string + organizationId: z.string(), +}) + +/** + * Parameters for tracking a contribution + */ +export interface TrackContributionParams { + cwd: string + filePath: string + unifiedDiff: string + status: "accepted" | "rejected" + taskId?: string + organizationId?: string + kilocodeToken: string +}