diff --git a/src/__tests__/main/ipc/handlers/symphony.test.ts b/src/__tests__/main/ipc/handlers/symphony.test.ts index 18d34133..578c98d9 100644 --- a/src/__tests__/main/ipc/handlers/symphony.test.ts +++ b/src/__tests__/main/ipc/handlers/symphony.test.ts @@ -5244,4 +5244,198 @@ This is a Symphony task document. expect(result.success).toBe(true); }); }); + + // ============================================================================ + // Manual Credit Tests (symphony:manualCredit) + // ============================================================================ + + describe('symphony:manualCredit', () => { + const getManualCreditHandler = () => handlers.get('symphony:manualCredit'); + + beforeEach(() => { + // Reset state to empty + vi.mocked(fs.readFile).mockRejectedValue(new Error('ENOENT')); + vi.mocked(fs.mkdir).mockResolvedValue(undefined); + vi.mocked(fs.writeFile).mockResolvedValue(undefined); + }); + + describe('validation', () => { + it('should reject missing required fields', async () => { + const handler = getManualCreditHandler(); + const result = await handler!({} as any, {}); + + // Handler returns { error: '...' }, wrapper adds success: true + // So validation errors show as { success: true, error: '...' } + expect(result.error).toContain('Missing required fields'); + expect(result.contributionId).toBeUndefined(); + }); + + it('should reject missing repoSlug', async () => { + const handler = getManualCreditHandler(); + const result = await handler!({} as any, { + repoName: 'Test Repo', + issueNumber: 123, + prNumber: 456, + prUrl: 'https://github.com/owner/repo/pull/456', + }); + + expect(result.error).toContain('Missing required fields'); + expect(result.contributionId).toBeUndefined(); + }); + + it('should reject duplicate PR credit', async () => { + // Setup existing state with a contribution + vi.mocked(fs.readFile).mockResolvedValue( + JSON.stringify({ + active: [], + history: [ + { + id: 'existing_contrib', + repoSlug: 'owner/repo', + prNumber: 456, + }, + ], + stats: { + totalContributions: 1, + totalMerged: 0, + totalIssuesResolved: 0, + totalDocumentsProcessed: 0, + totalTasksCompleted: 0, + totalTokensUsed: 0, + totalTimeSpent: 0, + estimatedCostDonated: 0, + repositoriesContributed: ['owner/repo'], + currentStreak: 0, + longestStreak: 0, + }, + }) + ); + + const handler = getManualCreditHandler(); + const result = await handler!({} as any, { + repoSlug: 'owner/repo', + repoName: 'Test Repo', + issueNumber: 123, + issueTitle: 'Test Issue', + prNumber: 456, + prUrl: 'https://github.com/owner/repo/pull/456', + }); + + expect(result.error).toContain('already credited'); + expect(result.contributionId).toBeUndefined(); + }); + }); + + describe('successful credit', () => { + it('should create a completed contribution with minimal params', async () => { + const handler = getManualCreditHandler(); + const result = await handler!({} as any, { + repoSlug: 'owner/repo', + repoName: 'Test Repo', + issueNumber: 123, + issueTitle: 'Test Issue', + prNumber: 456, + prUrl: 'https://github.com/owner/repo/pull/456', + }); + + expect(result.success).toBe(true); + expect(result.contributionId).toMatch(/^manual_123_/); + + // Verify state was written + expect(fs.writeFile).toHaveBeenCalled(); + const writeCall = vi.mocked(fs.writeFile).mock.calls[0]; + const writtenState = JSON.parse(writeCall[1] as string); + + expect(writtenState.history).toHaveLength(1); + expect(writtenState.history[0].repoSlug).toBe('owner/repo'); + expect(writtenState.history[0].prNumber).toBe(456); + expect(writtenState.stats.totalContributions).toBe(1); + }); + + it('should handle wasMerged flag correctly', async () => { + const handler = getManualCreditHandler(); + const result = await handler!({} as any, { + repoSlug: 'owner/repo', + repoName: 'Test Repo', + issueNumber: 123, + issueTitle: 'Test Issue', + prNumber: 456, + prUrl: 'https://github.com/owner/repo/pull/456', + wasMerged: true, + mergedAt: '2026-02-02T23:31:31Z', + }); + + expect(result.success).toBe(true); + + const writeCall = vi.mocked(fs.writeFile).mock.calls[0]; + const writtenState = JSON.parse(writeCall[1] as string); + + expect(writtenState.history[0].wasMerged).toBe(true); + expect(writtenState.history[0].mergedAt).toBe('2026-02-02T23:31:31Z'); + expect(writtenState.stats.totalMerged).toBe(1); + expect(writtenState.stats.totalIssuesResolved).toBe(1); + }); + + it('should add repo to repositoriesContributed if not already present', async () => { + const handler = getManualCreditHandler(); + await handler!({} as any, { + repoSlug: 'new-owner/new-repo', + repoName: 'New Repo', + issueNumber: 1, + issueTitle: 'Issue 1', + prNumber: 1, + prUrl: 'https://github.com/new-owner/new-repo/pull/1', + }); + + const writeCall = vi.mocked(fs.writeFile).mock.calls[0]; + const writtenState = JSON.parse(writeCall[1] as string); + + expect(writtenState.stats.repositoriesContributed).toContain('new-owner/new-repo'); + }); + + it('should accept custom token usage', async () => { + const handler = getManualCreditHandler(); + await handler!({} as any, { + repoSlug: 'owner/repo', + repoName: 'Test Repo', + issueNumber: 123, + issueTitle: 'Test Issue', + prNumber: 456, + prUrl: 'https://github.com/owner/repo/pull/456', + tokenUsage: { + inputTokens: 50000, + outputTokens: 25000, + totalCost: 1.5, + }, + }); + + const writeCall = vi.mocked(fs.writeFile).mock.calls[0]; + const writtenState = JSON.parse(writeCall[1] as string); + + expect(writtenState.history[0].tokenUsage.inputTokens).toBe(50000); + expect(writtenState.history[0].tokenUsage.outputTokens).toBe(25000); + expect(writtenState.history[0].tokenUsage.totalCost).toBe(1.5); + expect(writtenState.stats.totalTokensUsed).toBe(75000); + expect(writtenState.stats.estimatedCostDonated).toBe(1.5); + }); + + it('should set firstContributionAt on first credit', async () => { + const handler = getManualCreditHandler(); + await handler!({} as any, { + repoSlug: 'owner/repo', + repoName: 'Test Repo', + issueNumber: 123, + issueTitle: 'Test Issue', + prNumber: 456, + prUrl: 'https://github.com/owner/repo/pull/456', + }); + + const writeCall = vi.mocked(fs.writeFile).mock.calls[0]; + const writtenState = JSON.parse(writeCall[1] as string); + + expect(writtenState.stats.firstContributionAt).toBeDefined(); + expect(writtenState.stats.lastContributionAt).toBeDefined(); + }); + }); + }); }); diff --git a/src/main/ipc/handlers/symphony.ts b/src/main/ipc/handlers/symphony.ts index 7a64fcea..e72cb4cc 100644 --- a/src/main/ipc/handlers/symphony.ts +++ b/src/main/ipc/handlers/symphony.ts @@ -17,6 +17,7 @@ import { logger } from '../../utils/logger'; import { isWebContentsAvailable } from '../../utils/safe-send'; import { createIpcHandler, CreateHandlerOptions } from '../../utils/ipcHandler'; import { execFileNoThrow } from '../../utils/execFile'; +import { getExpandedEnv } from '../../agents/path-prober'; import { SYMPHONY_REGISTRY_URL, REGISTRY_CACHE_TTL_MS, @@ -575,7 +576,7 @@ async function createBranch( * Check if gh CLI is authenticated. */ async function checkGhAuthentication(): Promise<{ authenticated: boolean; error?: string }> { - const result = await execFileNoThrow('gh', ['auth', 'status']); + const result = await execFileNoThrow('gh', ['auth', 'status'], undefined, getExpandedEnv()); if (result.exitCode !== 0) { // gh auth status outputs to stderr even on success for some info const output = result.stderr + result.stdout; @@ -685,7 +686,8 @@ async function createDraftPR( '--body', body, ], - repoPath + repoPath, + getExpandedEnv() ); if (prResult.exitCode !== 0) { @@ -710,7 +712,12 @@ async function markPRReady( repoPath: string, prNumber: number ): Promise<{ success: boolean; error?: string }> { - const result = await execFileNoThrow('gh', ['pr', 'ready', String(prNumber)], repoPath); + const result = await execFileNoThrow( + 'gh', + ['pr', 'ready', String(prNumber)], + repoPath, + getExpandedEnv() + ); if (result.exitCode !== 0) { return { success: false, error: result.stderr }; @@ -833,7 +840,8 @@ This pull request was created using [Maestro Symphony](https://runmaestro.ai/sym const result = await execFileNoThrow( 'gh', ['pr', 'comment', String(prNumber), '--body', commentBody], - repoPath + repoPath, + getExpandedEnv() ); if (result.exitCode !== 0) { @@ -2579,5 +2587,161 @@ This PR will be updated automatically when the Auto Run completes.`; ) ); + /** + * Manually credit a contribution (for contributions made outside Symphony workflow). + * This allows crediting a user for work done on a PR that wasn't tracked through Symphony. + */ + ipcMain.handle( + 'symphony:manualCredit', + createIpcHandler( + handlerOpts('manualCredit'), + async (params: { + repoSlug: string; + repoName: string; + issueNumber: number; + issueTitle: string; + prNumber: number; + prUrl: string; + startedAt?: string; + completedAt?: string; + wasMerged?: boolean; + mergedAt?: string; + tokenUsage?: { + inputTokens?: number; + outputTokens?: number; + totalCost?: number; + }; + timeSpent?: number; + documentsProcessed?: number; + tasksCompleted?: number; + }): Promise<{ contributionId?: string; error?: string }> => { + const { + repoSlug, + repoName, + issueNumber, + issueTitle, + prNumber, + prUrl, + startedAt, + completedAt, + wasMerged, + mergedAt, + tokenUsage, + timeSpent, + documentsProcessed, + tasksCompleted, + } = params; + + // Validate required fields + if (!repoSlug || !repoName || !issueNumber || !prNumber || !prUrl) { + return { error: 'Missing required fields: repoSlug, repoName, issueNumber, prNumber, prUrl' }; + } + + const state = await readState(app); + + // Check if this PR is already credited + const existingContribution = state.history.find( + (c) => c.repoSlug === repoSlug && c.prNumber === prNumber + ); + if (existingContribution) { + return { error: `PR #${prNumber} is already credited (contribution: ${existingContribution.id})` }; + } + + const now = new Date().toISOString(); + const contributionId = `manual_${issueNumber}_${Date.now()}`; + + const completed: CompletedContribution = { + id: contributionId, + repoSlug, + repoName, + issueNumber, + issueTitle: issueTitle || `Issue #${issueNumber}`, + startedAt: startedAt || now, + completedAt: completedAt || now, + prUrl, + prNumber, + tokenUsage: { + inputTokens: tokenUsage?.inputTokens ?? 0, + outputTokens: tokenUsage?.outputTokens ?? 0, + totalCost: tokenUsage?.totalCost ?? 0, + }, + timeSpent: timeSpent ?? 0, + documentsProcessed: documentsProcessed ?? 0, + tasksCompleted: tasksCompleted ?? 1, + wasMerged: wasMerged ?? false, + mergedAt: mergedAt, + }; + + // Add to history + state.history.push(completed); + + // Update stats + state.stats.totalContributions += 1; + state.stats.totalDocumentsProcessed += completed.documentsProcessed; + state.stats.totalTasksCompleted += completed.tasksCompleted; + state.stats.totalTokensUsed += + completed.tokenUsage.inputTokens + completed.tokenUsage.outputTokens; + state.stats.totalTimeSpent += completed.timeSpent; + state.stats.estimatedCostDonated += completed.tokenUsage.totalCost; + + if (!state.stats.repositoriesContributed.includes(repoSlug)) { + state.stats.repositoriesContributed.push(repoSlug); + } + + if (wasMerged) { + state.stats.totalMerged = (state.stats.totalMerged || 0) + 1; + state.stats.totalIssuesResolved = (state.stats.totalIssuesResolved || 0) + 1; + } + + state.stats.lastContributionAt = completed.completedAt; + if (!state.stats.firstContributionAt) { + state.stats.firstContributionAt = completed.completedAt; + } + + // Update streak + const getWeekNumber = (date: Date): string => { + const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate())); + const dayNum = d.getUTCDay() || 7; + d.setUTCDate(d.getUTCDate() + 4 - dayNum); + const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1)); + const weekNo = Math.ceil(((d.getTime() - yearStart.getTime()) / 86400000 + 1) / 7); + return `${d.getUTCFullYear()}-W${weekNo}`; + }; + const currentWeek = getWeekNumber(new Date()); + const lastWeek = state.stats.lastContributionDate; + if (lastWeek) { + const oneWeekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000); + const previousWeek = getWeekNumber(oneWeekAgo); + if (lastWeek === previousWeek || lastWeek === currentWeek) { + if (lastWeek !== currentWeek) { + state.stats.currentStreak += 1; + } + } else { + state.stats.currentStreak = 1; + } + } else { + state.stats.currentStreak = 1; + } + state.stats.lastContributionDate = currentWeek; + if (state.stats.currentStreak > state.stats.longestStreak) { + state.stats.longestStreak = state.stats.currentStreak; + } + + await writeState(app, state); + + logger.info('Manual contribution credited', LOG_CONTEXT, { + contributionId, + repoSlug, + prNumber, + prUrl, + }); + + broadcastSymphonyUpdate(getMainWindow); + + return { contributionId }; + } + ) + ); + logger.info('Symphony handlers registered', LOG_CONTEXT); } diff --git a/src/main/preload/symphony.ts b/src/main/preload/symphony.ts index 65a3d853..896afda5 100644 --- a/src/main/preload/symphony.ts +++ b/src/main/preload/symphony.ts @@ -320,6 +320,28 @@ export function createSymphonyApi() { ): Promise<{ success: boolean; content?: string; error?: string }> => ipcRenderer.invoke('symphony:fetchDocumentContent', { url }), + manualCredit: (params: { + repoSlug: string; + repoName: string; + issueNumber: number; + issueTitle: string; + prNumber: number; + prUrl: string; + startedAt?: string; + completedAt?: string; + wasMerged?: boolean; + mergedAt?: string; + tokenUsage?: { + inputTokens?: number; + outputTokens?: number; + totalCost?: number; + }; + timeSpent?: number; + documentsProcessed?: number; + tasksCompleted?: number; + }): Promise<{ success: boolean; contributionId?: string; error?: string }> => + ipcRenderer.invoke('symphony:manualCredit', params), + // Real-time updates onUpdated: (callback: () => void) => { const handler = () => callback();