mirror of
https://github.com/jlengrand/Maestro.git
synced 2026-03-10 08:31:19 +00:00
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:
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user