feat(symphony): add manual contribution credit handler

Add symphony:manualCredit IPC handler to allow crediting contributions
made outside the Symphony workflow (e.g., manual PRs, external
contributors). This enables proper tracking of all contributions
regardless of how they were created.

- Add symphony:manualCredit handler with full validation
- Add preload API for manual credit
- Support all contribution fields (tokens, time, merged status, etc.)
- Prevent duplicate PR credits
- Update contributor stats (streak, repos, totals)
- Add comprehensive tests for validation and success cases
This commit is contained in:
Pedram Amini
2026-02-03 01:24:01 -06:00
parent 81c64d9858
commit 99f4257c17
3 changed files with 384 additions and 4 deletions

View File

@@ -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();
});
});
});
});

View File

@@ -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);
}

View File

@@ -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();