diff --git a/CODEBASE_DOCUMENTATION.md b/CODEBASE_DOCUMENTATION.md index 63d3be44..1ed6d5b1 100644 --- a/CODEBASE_DOCUMENTATION.md +++ b/CODEBASE_DOCUMENTATION.md @@ -83,6 +83,7 @@ server/auditExportService.js - Redacted audit export across activity + sch server/networkSecurityPolicy.js - Bind-host/auth safety policy helpers (loopback defaults + LAN auth guardrails) server/processTelemetryBenchmarkService.js - Release benchmark metrics (onboarding/runtime/review), snapshot comparisons, release-note markdown generation server/projectTypeService.js - Project taxonomy loader/validator for category→framework→template metadata (`config/project-types.json`) +server/prReviewAutomationService.js - PR review automation pipeline (poll/webhook trigger, reviewer selection, worktree assignment, feedback routing, saved review snapshots) ``` ### Multi-Workspace System (Core Feature) @@ -287,6 +288,56 @@ user-settings.json - User preferences and workspace settings user-settings.default.json - Default user settings template ``` +#### PR Review Automation Defaults + +`global.ui.tasks.automations.prReview` includes these keys in `user-settings.default.json`: + +- `enabled`: boolean +- `pollEnabled`: boolean +- `pollMs`: number +- `webhookEnabled`: boolean +- `reviewerAgent`: `claude` | `codex` +- `reviewerMode`: `fresh` | `continue` | `resume` +- `reviewerProvider`: provider string (Claude-only) +- `reviewerClaudeModel`: optional Claude CLI `--model` value such as `opus`, `sonnet`, or a full model id +- `reviewerSkipPermissions`: boolean (Claude-only) +- `reviewerCodexModel`: optional Codex CLI `-m` model override (blank uses the local Codex default/config) +- `reviewerCodexReasoning`: `low` | `medium` | `high` | `xhigh` (Codex-only; blank uses the local Codex default/config) +- `reviewerCodexVerbosity`: `low` | `medium` | `high` (Codex-only; blank uses the local Codex default/config) +- `reviewerCodexFlags`: array of Codex flags +- `reviewerTier`: number +- `autoSpawnReviewer`: boolean +- `autoFeedbackToAuthor`: boolean +- `autoSpawnFixer`: boolean +- `notifyOnReviewerSpawn`: boolean +- `notifyOnReviewCompleted`: boolean +- `approvedDeliveryAction`: `notify` | `paste` | `paste_and_notify` | `none` +- `commentedDeliveryAction`: `notify` | `paste` | `paste_and_notify` | `none` +- `needsFixFeedbackAction`: `notify` | `paste` | `paste_and_notify` | `none` +- `reviewerPostAction`: optional per-item record override (`feedback` or `auto_fix`) +- `maxConcurrentReviewers`: number +- `repos`: string array + +Saved task-record review fields used by Queue + feedback handoff include: +- `reviewSourceSessionId` / `reviewSourceWorktreeId`: best-effort source agent to paste review updates back into +- `reviewerSessionId` / `reviewerAgent`: which agent/worktree handled the background review +- `latestReviewSummary` / `latestReviewBody`: locally cached review snapshot for Queue details + paste-back +- `latestReviewOutcome` / `latestReviewUser` / `latestReviewUrl` / `latestReviewSubmittedAt`: latest GitHub review metadata +- `latestReviewDeliveredAt`: when the saved review summary was pasted back into an agent session + +When a reviewer completes, the result is routed by outcome: +- `approvedDeliveryAction` / `commentedDeliveryAction`: optional notify and/or paste-back to the source agent +- `reviewerPostAction=feedback`: use `needsFixFeedbackAction` to notify and/or paste changes-requested feedback back to the source agent +- `reviewerPostAction=auto_fix`: a fixer agent is spawned automatically and uses the stored review body as the fix request + +Queue detail actions now include saved-review affordances: +- `Open latest review`: opens the saved review URL (or PR URL fallback) +- `Paste to agent`: writes the saved review summary/body back into the source agent terminal via the existing terminal-input path +- agent terminal headers also surface `⏳` while a review is running plus `📝` / `↩` buttons once a saved review snapshot exists for that session's linked PR + +Source-session linking is no longer Queue-only: +- whenever any non-server terminal picks up an `existingPR` link through branch detection/session restore, the client now upserts `reviewSourceSessionId` / `reviewSourceWorktreeId` on the PR task record so background review completion can route back to that terminal automatically + ### Workspace Templates & Scripts ``` templates/launch-settings/ - Workspace configuration templates @@ -459,6 +510,10 @@ GET /api/project-types/categories - Project categories with resolved base paths GET /api/project-types/frameworks?categoryId=... - Framework catalog (optionally scoped by category) GET /api/project-types/templates?frameworkId=...&categoryId=... - Template catalog (optionally scoped) POST /api/projects/create-workspace - Create project scaffold + matching workspace in one request +GET /api/process/automations - Combined automation status (merge + review) +GET /api/process/automations/pr-review/status - PR review automation status + timestamps +POST /api/process/automations/pr-review/run - Trigger one PR review automation cycle +PUT /api/process/automations/pr-review/config - Update PR review automation settings GET /api/discord/status - Discord queue + services health/status (counts + signature status); endpoint can be gated by `DISCORD_API_TOKEN` POST /api/discord/ensure-services - Ensure Services workspace/session bootstrap; accepts optional `dangerousModeOverride` (gated by `DISCORD_ALLOW_DANGEROUS_OVERRIDE`) POST /api/discord/process-queue - Dispatch queue processing prompt with optional `Idempotency-Key`/`idempotencyKey`, queue signature verification, idempotent replay, audit logging, and per-endpoint rate limiting diff --git a/client/app.js b/client/app.js index 9a0cccdb..ecdd90e8 100644 --- a/client/app.js +++ b/client/app.js @@ -935,9 +935,24 @@ class ClaudeOrchestrator { }); document.getElementById('workflow-review')?.addEventListener('click', () => { this.setWorkflowMode('review'); - // Batch review defaults (Tier 3, unreviewed, console+diff ready). - this.queuePanelPreset = { reviewTier: 3, unreviewedOnly: true, autoOpenDiff: true, autoConsole: true, autoAdvance: true, reviewActive: true }; - this.showQueuePanel(); + const reviewContext = this.getCurrentReviewContext(); + // Open a simple PR-first picker; power-user review lanes still live under Flows. + this.queuePanelPreset = { + mode: 'mine', + kindFilter: 'pr', + reviewTier: 'all', + tierSet: null, + unreviewedOnly: false, + blockedOnly: false, + autoOpenDiff: false, + autoConsole: false, + autoAdvance: false, + reviewActive: false, + prioritizeActive: true, + projectFilter: '', + repoScope: reviewContext.repoScope || '' + }; + this.showQueuePanel({ selectedId: reviewContext.prTaskId || null }); }); document.getElementById('workflow-background')?.addEventListener('click', () => { this.setWorkflowMode('background'); @@ -1151,6 +1166,10 @@ class ClaudeOrchestrator { this.notificationManager.handleNotification(notification); }); + this.socket.on('pr-review-automation', (payload) => { + this.handlePrReviewAutomationEvent(payload); + }); + this.socket.on('session-exited', ({ sessionId, exitCode }) => { this.handleSessionExit(sessionId, exitCode); }); @@ -1204,6 +1223,7 @@ class ClaudeOrchestrator { const links = this.githubLinks.get(sessionId) || {}; links.pr = sessionState.existingPR; this.githubLinks.set(sessionId, links); + this.maybeLinkPrTaskToSession(sessionId, sessionState.existingPR, { sessionOverride: this.sessions.get(sessionId) }).catch(() => {}); } // Mark new sessions as active (active-only filter should treat background work as active too). @@ -2088,6 +2108,58 @@ class ClaudeOrchestrator { }); } + const prReviewEnabled = document.getElementById('pr-review-auto-enabled'); + if (prReviewEnabled) { + prReviewEnabled.addEventListener('change', (e) => { + this.updateGlobalUserSetting('ui.tasks.automations.prReview.enabled', !!e.target.checked); + }); + } + const prReviewReviewerAgent = document.getElementById('pr-review-reviewer-agent'); + if (prReviewReviewerAgent) { + prReviewReviewerAgent.addEventListener('change', (e) => { + const next = String(e.target.value || '').trim().toLowerCase() === 'codex' ? 'codex' : 'claude'; + this.updateGlobalUserSetting('ui.tasks.automations.prReview.reviewerAgent', next); + }); + } + const prReviewReviewerMode = document.getElementById('pr-review-reviewer-mode'); + if (prReviewReviewerMode) { + prReviewReviewerMode.addEventListener('change', (e) => { + const raw = String(e.target.value || '').trim().toLowerCase(); + const next = raw === 'continue' ? 'continue' : 'fresh'; + this.updateGlobalUserSetting('ui.tasks.automations.prReview.reviewerMode', next); + }); + } + const prReviewNotifyStarted = document.getElementById('pr-review-notify-started'); + if (prReviewNotifyStarted) { + prReviewNotifyStarted.addEventListener('change', (e) => { + this.updateGlobalUserSetting('ui.tasks.automations.prReview.notifyOnReviewerSpawn', !!e.target.checked); + }); + } + const prReviewNotifyCompleted = document.getElementById('pr-review-notify-completed'); + if (prReviewNotifyCompleted) { + prReviewNotifyCompleted.addEventListener('change', (e) => { + this.updateGlobalUserSetting('ui.tasks.automations.prReview.notifyOnReviewCompleted', !!e.target.checked); + }); + } + const prReviewApprovedAction = document.getElementById('pr-review-approved-action'); + if (prReviewApprovedAction) { + prReviewApprovedAction.addEventListener('change', (e) => { + this.updateGlobalUserSetting('ui.tasks.automations.prReview.approvedDeliveryAction', String(e.target.value || 'notify').trim()); + }); + } + const prReviewCommentedAction = document.getElementById('pr-review-commented-action'); + if (prReviewCommentedAction) { + prReviewCommentedAction.addEventListener('change', (e) => { + this.updateGlobalUserSetting('ui.tasks.automations.prReview.commentedDeliveryAction', String(e.target.value || 'notify').trim()); + }); + } + const prReviewNeedsFixAction = document.getElementById('pr-review-needs-fix-action'); + if (prReviewNeedsFixAction) { + prReviewNeedsFixAction.addEventListener('change', (e) => { + this.updateGlobalUserSetting('ui.tasks.automations.prReview.needsFixFeedbackAction', String(e.target.value || 'paste_and_notify').trim()); + }); + } + const identityClaimName = document.getElementById('identity-claim-name'); if (identityClaimName) { identityClaimName.addEventListener('change', async (e) => { @@ -3100,7 +3172,8 @@ class ClaudeOrchestrator { async openReviewInbox({ quick = false, project = '' } = {}) { this.setWorkflowMode('review'); const defaults = this.getReviewInboxDefaults(quick ? 'quickReview' : 'reviewInbox'); - const projectFilter = String(project || defaults.project || '').trim(); + const reviewContext = this.getCurrentReviewContext({ project }); + const projectFilter = String(reviewContext.projectFilter || defaults.project || '').trim(); this.queuePanelPreset = { mode: defaults.mode, reviewTier: defaults.reviewTier, @@ -3115,10 +3188,11 @@ class ClaudeOrchestrator { reviewRouteActive: false, kindFilter: defaults.kind, projectFilter: projectFilter || '', + repoScope: reviewContext.repoScope || '', prioritizeActive: defaults.prioritizeActive, quickReview: !!quick }; - return this.showQueuePanel(); + return this.showQueuePanel({ selectedId: reviewContext.prTaskId || null }); } async openQuickReview({ project = '' } = {}) { @@ -3522,6 +3596,283 @@ class ClaudeOrchestrator { return `pr:${owner}/${repo}#${prNum}`; } + extractGitHubRepoSlug(value) { + const raw = String(value || '').trim(); + if (!raw) return ''; + + const directMatch = raw.match(/^([^/\s]+)\/([^/\s]+?)(?:\.git)?$/); + if (directMatch) return `${directMatch[1]}/${directMatch[2]}`; + + const normalized = raw.replace(/\.git(?=$|[/?#])/i, ''); + const urlMatch = normalized.match(/github\.com[:/]([^/:\s]+)\/([^/#?\s]+?)(?:[/?#]|$)/i); + if (urlMatch) return `${urlMatch[1]}/${urlMatch[2]}`; + + return ''; + } + + getCurrentReviewContext({ project = '' } = {}) { + const explicitProject = String(project || '').trim(); + const explicitRepo = this.extractGitHubRepoSlug(explicitProject); + const candidateSessionIds = []; + const repoSlugs = new Set(); + const pushCandidate = (value) => { + const sid = String(value || '').trim(); + if (!sid || candidateSessionIds.includes(sid)) return; + candidateSessionIds.push(sid); + }; + const addRepoSlug = (value) => { + const slug = this.extractGitHubRepoSlug(value); + if (!slug) return ''; + repoSlugs.add(slug); + return slug; + }; + const normalizePath = (value) => String(value || '').trim().replace(/\\/g, '/').replace(/\/+$/, ''); + const workspaceTerminalIds = new Set( + (Array.isArray(this.currentWorkspace?.terminals) ? this.currentWorkspace.terminals : []) + .map((terminal) => String(terminal?.id || '').trim()) + .filter(Boolean) + ); + const workspaceRepoPaths = new Set( + (Array.isArray(this.currentWorkspace?.terminals) ? this.currentWorkspace.terminals : []) + .map((terminal) => normalizePath(terminal?.repository?.path)) + .filter(Boolean) + ); + const workspaceRepoPath = normalizePath(this.currentWorkspace?.repository?.path); + const addSessionRepoContext = (sessionId, session) => { + if (!sessionId || !session) return; + const links = this.githubLinks.get(sessionId) || {}; + addRepoSlug(session?.repositorySlug); + addRepoSlug(links.pr); + addRepoSlug(links.commit); + addRepoSlug(session?.remoteUrl); + }; + const deriveSessionRepoPath = (sessionId, session) => { + const directRoot = normalizePath(session?.repositoryRoot); + if (directRoot) return directRoot; + const cwd = normalizePath(this.resolveWorktreePathForSession(sessionId, session)); + const worktreeId = String(session?.worktreeId || '').trim(); + if (cwd && worktreeId && cwd.endsWith(`/${worktreeId}`)) { + return cwd.slice(0, -(`/${worktreeId}`).length); + } + return ''; + }; + const belongsToCurrentWorkspace = (sessionId, session) => { + const sid = String(sessionId || '').trim(); + if (!sid || !session) return false; + + const currentWorkspaceId = String(this.currentWorkspace?.id || '').trim(); + const sessionWorkspaceId = String(session?.workspace || '').trim(); + if (currentWorkspaceId && sessionWorkspaceId) return sessionWorkspaceId === currentWorkspaceId; + + if (workspaceTerminalIds.size && workspaceTerminalIds.has(sid)) return true; + + const sessionRepoPath = deriveSessionRepoPath(sid, session); + if (workspaceRepoPath && sessionRepoPath && sessionRepoPath === workspaceRepoPath) return true; + if (workspaceRepoPaths.size && sessionRepoPath && workspaceRepoPaths.has(sessionRepoPath)) return true; + + return !currentWorkspaceId; + }; + + pushCandidate(this.focusedTerminalInfo?.sessionId); + pushCandidate(this.lastInteractedSessionId); + + let prTaskId = ''; + let repoSlug = addRepoSlug(explicitRepo); + + for (const sessionId of candidateSessionIds) { + const session = this.sessions.get(sessionId); + if (!session) continue; + const links = this.githubLinks.get(sessionId) || {}; + + if (!prTaskId) prTaskId = this.getPRTaskIdFromUrl(links.pr) || ''; + if (!repoSlug) repoSlug = addRepoSlug(session?.repositorySlug); + if (!repoSlug) { + repoSlug = addRepoSlug(links.pr) + || addRepoSlug(links.commit) + || addRepoSlug(session?.remoteUrl); + } + addSessionRepoContext(sessionId, session); + } + + for (const [sid, session] of this.sessions) { + if (!belongsToCurrentWorkspace(sid, session)) continue; + addSessionRepoContext(sid, session); + } + + addRepoSlug(this.currentWorkspace?.repository?.remote); + addRepoSlug(this.currentWorkspace?.remoteUrl); + + if (!repoSlug) repoSlug = Array.from(repoSlugs)[0] || ''; + if (!repoSlug && prTaskId) repoSlug = this.getRepositorySlugForPRTask({ id: prTaskId }); + if (repoSlug) addRepoSlug(repoSlug); + + return { + repoSlug: repoSlug || '', + repoScope: Array.from(repoSlugs).join(','), + projectFilter: explicitProject || '', + prTaskId: prTaskId || '' + }; + } + + getRepositorySlugForPRTask(task) { + const t = task && typeof task === 'object' ? task : {}; + const direct = String(t.repository || '').trim(); + if (direct) return direct; + + const parse = (value) => { + const raw = String(value || '').trim(); + if (!raw) return ''; + + const prTaskMatch = raw.match(/^pr:([^/]+\/[^#]+)#\d+$/i); + if (prTaskMatch) return prTaskMatch[1]; + + return this.extractGitHubRepoSlug(raw); + }; + + return parse(t.url) || parse(t.id) || ''; + } + + async maybeLinkPrTaskToSession(sessionId, prUrl, { sessionOverride = null } = {}) { + const sid = String(sessionId || '').trim(); + const url = String(prUrl || '').trim(); + if (!sid || !url) return null; + if (sid.endsWith('-server')) return null; + + const prTaskId = this.getPRTaskIdFromUrl(url); + if (!prTaskId) return null; + + const session = sessionOverride || this.sessions.get(sid) || null; + const worktreePath = this.resolveWorktreePathForSession(sid, session); + const worktreeId = String(session?.worktreeId || this.extractWorktreeLabel(worktreePath) || '').trim(); + + const existing = this.taskRecords.get(prTaskId) || {}; + if ( + String(existing?.reviewSourceSessionId || '').trim() === sid + && String(existing?.reviewSourceWorktreeId || '').trim() === worktreeId + ) { + return existing; + } + + const patch = { + reviewSourceSessionId: sid, + reviewSourceWorktreeId: worktreeId || null + }; + + try { + const rec = await this.upsertTaskRecord(prTaskId, patch); + if (rec) { + const merged = { ...(existing && typeof existing === 'object' ? existing : {}), ...rec }; + this.taskRecords.set(prTaskId, merged); + this.queuePanelApi?.handleAutomationEvent?.({ prId: prTaskId, recordPatch: rec }); + this.updateTerminalControlsForPrTask(prTaskId); + return merged; + } + } catch (error) { + console.warn('Failed to link PR task to source session:', { sessionId: sid, prUrl: url, error: error?.message || error }); + } + + return null; + } + + getPrReviewMetaForSession(sessionId) { + const sid = String(sessionId || '').trim(); + if (!sid) return null; + + const prUrl = String(this.githubLinks.get(sid)?.pr || '').trim(); + const prTaskId = prUrl ? this.getPRTaskIdFromUrl(prUrl) : null; + if (!prTaskId) return null; + + const record = this.taskRecords.get(prTaskId) || {}; + const latestReviewSummary = String(record?.latestReviewSummary || '').trim(); + const latestReviewBody = String(record?.latestReviewBody || '').trim(); + const latestReviewOutcome = String(record?.latestReviewOutcome || '').trim().toLowerCase(); + const reviewPending = !!record?.reviewerSpawnedAt && !record?.reviewedAt; + + return { + prTaskId, + prUrl, + record, + latestReviewSummary, + latestReviewBody, + latestReviewOutcome, + hasLatestReview: !!(latestReviewSummary || latestReviewBody), + reviewPending + }; + } + + getLinkedSessionIdsForPrTask(prTaskId) { + const taskId = String(prTaskId || '').trim(); + if (!taskId) return []; + + const linked = new Set(); + for (const [sessionId, links] of this.githubLinks.entries()) { + const prUrl = String(links?.pr || '').trim(); + if (!prUrl) continue; + if (this.getPRTaskIdFromUrl(prUrl) === taskId) linked.add(sessionId); + } + + const record = this.taskRecords.get(taskId) || {}; + const sourceSessionId = String(record?.reviewSourceSessionId || '').trim(); + if (sourceSessionId) linked.add(sourceSessionId); + + return Array.from(linked); + } + + updateTerminalControlsForPrTask(prTaskId) { + this.getLinkedSessionIdsForPrTask(prTaskId).forEach((sessionId) => { + this.updateTerminalControls(sessionId); + }); + } + + openLatestReviewForSession(sessionId) { + const meta = this.getPrReviewMetaForSession(sessionId); + if (!meta?.prTaskId) { + this.showToast?.('No linked PR review found for this terminal', 'warning'); + return false; + } + return this.openLatestReviewForTask({ id: meta.prTaskId, url: meta.prUrl, record: meta.record }); + } + + pasteLatestReviewToSessionFromHeader(sessionId) { + const sid = String(sessionId || '').trim(); + const meta = this.getPrReviewMetaForSession(sid); + if (!sid || !meta?.prTaskId) { + this.showToast?.('No linked PR review found for this terminal', 'warning'); + return false; + } + + const payload = this.buildLatestReviewMessageForTask({ id: meta.prTaskId, url: meta.prUrl, record: meta.record }); + if (!payload) { + this.showToast?.('No saved review summary is available yet', 'warning'); + return false; + } + + this.sendTerminalInput(sid, `${payload}\n`); + this.showToast?.(`Pasted saved review into ${sid}`, 'success'); + return true; + } + + getPrReviewButtons(sessionId) { + const sid = String(sessionId || '').trim(); + const meta = this.getPrReviewMetaForSession(sid); + if (!meta) return ''; + + const parts = []; + if (meta.reviewPending) { + parts.push(``); + } + + if (meta.hasLatestReview) { + const outcome = meta.latestReviewOutcome || 'review'; + const openTitle = `Open saved ${outcome} review`; + const pasteTitle = `Paste saved ${outcome} review back into this terminal`; + parts.push(``); + parts.push(``); + } + + return parts.join(''); + } + getTierForSession(sessionId) { const session = this.sessions.get(sessionId); const prUrl = this.githubLinks.get(sessionId)?.pr || null; @@ -3613,6 +3964,7 @@ class ClaudeOrchestrator { links.pr = state.existingPR; this.githubLinks.set(sessionId, links); console.log('Loaded existing PR for session:', sessionId, state.existingPR); + this.maybeLinkPrTaskToSession(sessionId, state.existingPR, { sessionOverride: sessionData }).catch(() => {}); } // For mixed-repo workspaces, set terminals as active immediately so they show by default @@ -6018,6 +6370,7 @@ class ClaudeOrchestrator { this.githubLinks.set(sessionId, links); console.log('Automatically detected existing PR:', existingPR); this.maybeSchedulePrIntentRefresh(sessionId, existingPR); + this.maybeLinkPrTaskToSession(sessionId, existingPR, { sessionOverride: session }).catch(() => {}); } } @@ -6297,6 +6650,7 @@ class ClaudeOrchestrator { } } + buttons += this.getPrReviewButtons(sessionId); return buttons; } @@ -6549,6 +6903,180 @@ class ClaudeOrchestrator { } } + resolvePreferredReviewSourceSession(task) { + const t = task && typeof task === 'object' ? task : {}; + const recordFromMap = t?.id ? (this.taskRecords.get(String(t.id)) || {}) : {}; + const record = (t.record && typeof t.record === 'object') + ? { ...recordFromMap, ...t.record } + : recordFromMap; + + const candidateIds = [ + record.reviewSourceSessionId, + t.sessionId + ] + .map((value) => String(value || '').trim()) + .filter(Boolean); + + for (const sessionId of candidateIds) { + const session = this.sessions.get(sessionId); + if (session && session.status !== 'exited') return sessionId; + } + + const candidateWorktreeId = String( + record.reviewSourceWorktreeId + || this.extractWorktreeLabel(String(t.worktreePath || '').trim()) + || '' + ).trim().toLowerCase(); + + const candidateWorktreePath = String( + t.worktreePath + || (t.sessionId ? this.resolveWorktreePathForSession(t.sessionId) : '') + || '' + ).trim(); + + let fallbackSessionId = null; + for (const [sessionId, session] of this.sessions.entries()) { + if (!/-(claude|codex)$/.test(String(sessionId || ''))) continue; + if (session?.status === 'exited') continue; + + const sessionWorktreePath = this.resolveWorktreePathForSession(sessionId, session); + if (candidateWorktreePath && sessionWorktreePath && sessionWorktreePath === candidateWorktreePath) { + return sessionId; + } + + if (!fallbackSessionId && candidateWorktreeId) { + const inferred = String(session?.worktreeId || this.extractWorktreeLabel(sessionWorktreePath) || '').trim().toLowerCase(); + if (inferred && inferred === candidateWorktreeId) { + fallbackSessionId = sessionId; + } + } + } + + return fallbackSessionId; + } + + buildLatestReviewMessageForTask(task) { + const t = task && typeof task === 'object' ? task : {}; + const recordFromMap = t?.id ? (this.taskRecords.get(String(t.id)) || {}) : {}; + const record = (t.record && typeof t.record === 'object') + ? { ...recordFromMap, ...t.record } + : recordFromMap; + + const summary = String(record.latestReviewSummary || '').trim(); + const body = String(record.latestReviewBody || '').trim(); + const outcome = String(record.latestReviewOutcome || '').trim().toLowerCase(); + const outcomeLabel = outcome === 'approved' + ? 'APPROVED' + : outcome === 'commented' + ? 'COMMENTED' + : outcome === 'needs_fix' + ? 'CHANGES REQUESTED' + : 'REVIEW UPDATE'; + const reviewUser = String(record.latestReviewUser || '').trim() || 'AI reviewer'; + const reviewUrl = String(record.latestReviewUrl || t.url || '').trim(); + const reviewAgent = String(record.latestReviewAgent || record.reviewerAgent || '').trim(); + const prNumber = String(t.prNumber || '').trim() || (String(t.id || '').match(/#(\d+)$/)?.[1] || '?'); + const text = summary || body; + if (!text) return ''; + + return [ + '', + '--- Saved PR Review ---', + `PR #${prNumber} reviewed by ${reviewUser}.`, + `Outcome: ${outcomeLabel}`, + reviewAgent ? `Reviewer agent: ${reviewAgent}` : '', + reviewUrl ? `GitHub: ${reviewUrl}` : '', + '', + text, + '', + '--- End Saved PR Review ---', + '' + ].filter(Boolean).join('\n'); + } + + openLatestReviewForTask(task) { + const t = task && typeof task === 'object' ? task : {}; + const recordFromMap = t?.id ? (this.taskRecords.get(String(t.id)) || {}) : {}; + const record = (t.record && typeof t.record === 'object') + ? { ...recordFromMap, ...t.record } + : recordFromMap; + const url = String(record.latestReviewUrl || t.url || '').trim(); + if (!url) { + this.showToast?.('No saved review URL for this task', 'warning'); + return false; + } + + try { + window.open(url, '_blank', 'noopener'); + return true; + } catch (error) { + this.showToast?.(String(error?.message || error), 'error'); + return false; + } + } + + pasteLatestReviewToTaskSession(task) { + const t = task && typeof task === 'object' ? task : {}; + const sessionId = this.resolvePreferredReviewSourceSession(t); + if (!sessionId) { + this.showToast?.('No active agent session found for this review', 'warning'); + return false; + } + + const payload = this.buildLatestReviewMessageForTask(t); + if (!payload) { + this.showToast?.('No saved review summary is available yet', 'warning'); + return false; + } + + this.sendTerminalInput(sessionId, `${payload}\n`); + this.showToast?.(`Pasted saved review into ${sessionId}`, 'success'); + return true; + } + + handlePrReviewAutomationEvent(payload = {}) { + const event = String(payload?.event || '').trim().toLowerCase(); + const prId = String(payload?.prId || '').trim(); + const recordPatch = (payload?.recordPatch && typeof payload.recordPatch === 'object') ? payload.recordPatch : null; + if (prId && recordPatch) { + const existing = this.taskRecords.get(prId) || {}; + this.taskRecords.set(prId, { ...(existing && typeof existing === 'object' ? existing : {}), ...recordPatch }); + } + + this.queuePanelApi?.handleAutomationEvent?.(payload); + if (prId) this.updateTerminalControlsForPrTask(prId); + + const cfg = this.userSettings?.global?.ui?.tasks?.automations?.prReview || {}; + const label = prId || 'PR review'; + + if (event === 'reviewer-spawned') { + if (cfg.notifyOnReviewerSpawn === false) return; + const agentLabel = String(payload?.agentId || '').trim() || 'agent'; + const message = `Reviewer started for ${label} (${agentLabel})`; + this.showToast?.(message, 'info'); + this.notificationManager?.handleNotification?.({ type: 'completed', message, sessionId: payload?.sessionId || null }); + return; + } + + if (event === 'review-completed') { + if (cfg.notifyOnReviewCompleted === false) return; + const outcome = String(payload?.outcome || '').trim().toLowerCase(); + const summary = String(payload?.reviewSummary || '').trim(); + const message = summary + ? `${label}: ${outcome || 'completed'} — ${summary}` + : `${label}: ${outcome || 'completed'}`; + const toastType = outcome === 'needs_fix' ? 'warning' : (outcome === 'approved' ? 'success' : 'info'); + const notificationType = outcome === 'needs_fix' ? 'waiting' : 'completed'; + this.showToast?.(message, toastType, { durationMs: outcome === 'needs_fix' ? 8000 : 5000 }); + this.notificationManager?.handleNotification?.({ + type: notificationType, + message, + sessionId: payload?.pastedToSessionId || null, + metadata: { prId, outcome, reviewUrl: payload?.reviewUrl || null } + }); + } + } + resizeTerminal(sessionId, cols, rows) { if (this.socket && this.socket.connected) { this.socket.emit('terminal-resize', { sessionId, cols, rows }); @@ -7157,6 +7685,7 @@ class ClaudeOrchestrator { .catch?.((err) => console.error('Failed to refresh conflicts:', err)); break; + case 'queue-review-pr': case 'queue-spawn-reviewer': this.showQueuePanel?.() .then(() => setTimeout(() => this.queuePanelApi?.spawnReviewerSelected?.(), 50)) @@ -8738,18 +9267,19 @@ class ClaudeOrchestrator { No available reviewers `; - } else { + } else { availableReviewers.forEach(({ sessionId, session, worktreeNumber, status }) => { const statusClass = status === 'waiting' ? 'ready' : status === 'busy' ? 'busy' : 'inactive'; + const reviewerAgent = String(sessionId || '').includes('-codex') ? 'Codex' : 'Claude'; html += `
- 🤖 Claude ${worktreeNumber} + 🤖 ${reviewerAgent} ${worktreeNumber} (${session.branch || 'unknown'})
`; }); - } + } return html; } @@ -8758,21 +9288,24 @@ class ClaudeOrchestrator { const reviewers = []; for (const [sessionId, session] of this.sessions) { - // Only include Claude sessions that are not the requesting session - if (sessionId.includes('-claude') && sessionId !== requestingSessionId) { - const worktreeNumber = sessionId.replace('-claude', '').replace('work', ''); - const isActive = this.sessionActivity.get(sessionId) === 'active'; - - // Prefer active sessions, but include inactive ones as backup - if (isActive || session.status === 'waiting') { - reviewers.push({ - sessionId, - session, - worktreeNumber, - status: session.status, - isActive - }); - } + // Only include agent sessions that are not the requesting session + const isAgentSession = /-(claude|codex)$/.test(String(sessionId || '')); + if (!isAgentSession || String(sessionId || '') === String(requestingSessionId || '')) { + continue; + } + + const worktreeNumber = String(sessionId || '').replace(/-(claude|codex)$/, '').replace('work', ''); + const isActive = this.sessionActivity.get(sessionId) === 'active'; + + // Prefer active sessions, but include inactive ones as backup + if (isActive || session.status === 'waiting') { + reviewers.push({ + sessionId, + session, + worktreeNumber, + status: session.status, + isActive + }); } } @@ -8797,14 +9330,16 @@ class ClaudeOrchestrator { const codeInfo = await this.extractCodeForReview(requestingSessionId); if (!codeInfo.hasContent) { - this.showToast(`No code changes detected in Claude ${requestingSessionId.replace('work', '').replace('-claude', '')}`, 'warning'); + const requestWorktree = String(requestingSessionId || '').replace(/-(claude|codex)$/, '').replace('work', ''); + const requestAgent = String(requestingSessionId || '').includes('-codex') ? 'Codex' : 'Claude'; + this.showToast(`No code changes detected in ${requestAgent} ${requestWorktree}`, 'warning'); return; } // Format review request const reviewRequest = this.formatReviewRequest(codeInfo, requestingSessionId); - // Send to reviewer Claude + // Send to reviewer session this.sendTerminalInput(reviewerSessionId, reviewRequest); // Mark both sessions as active @@ -8812,9 +9347,11 @@ class ClaudeOrchestrator { this.buildSidebar(); // Show success message - const requestingWorktree = requestingSessionId.replace('work', '').replace('-claude', ''); - const reviewerWorktree = reviewerSessionId.replace('work', '').replace('-claude', ''); - this.showToast(`Code review assigned: Claude ${requestingWorktree} → Claude ${reviewerWorktree}`, 'success'); + const requestingWorktree = String(requestingSessionId || '').replace(/-(claude|codex)$/, '').replace('work', ''); + const reviewerWorktree = String(reviewerSessionId || '').replace(/-(claude|codex)$/, '').replace('work', ''); + const requestingAgent = String(requestingSessionId || '').includes('-codex') ? 'Codex' : 'Claude'; + const reviewerAgent = String(reviewerSessionId || '').includes('-codex') ? 'Codex' : 'Claude'; + this.showToast(`Code review assigned: ${requestingAgent} ${requestingWorktree} → ${reviewerAgent} ${reviewerWorktree}`, 'success'); } catch (error) { console.error('Error assigning code review:', error); @@ -16712,6 +17249,43 @@ class ClaudeOrchestrator { prMergeComment.checked = cfg.comment !== false; } + const prReviewCfg = this.userSettings.global?.ui?.tasks?.automations?.prReview || {}; + const prReviewEnabled = document.getElementById('pr-review-auto-enabled'); + if (prReviewEnabled) { + prReviewEnabled.checked = prReviewCfg.enabled === true; + } + const prReviewReviewerAgent = document.getElementById('pr-review-reviewer-agent'); + if (prReviewReviewerAgent) { + prReviewReviewerAgent.value = String(prReviewCfg.reviewerAgent || 'claude').trim().toLowerCase() === 'codex' ? 'codex' : 'claude'; + } + const prReviewReviewerMode = document.getElementById('pr-review-reviewer-mode'); + if (prReviewReviewerMode) { + prReviewReviewerMode.value = String(prReviewCfg.reviewerMode || 'fresh').trim().toLowerCase() === 'continue' ? 'continue' : 'fresh'; + } + const prReviewNotifyStarted = document.getElementById('pr-review-notify-started'); + if (prReviewNotifyStarted) { + prReviewNotifyStarted.checked = prReviewCfg.notifyOnReviewerSpawn !== false; + } + const prReviewNotifyCompleted = document.getElementById('pr-review-notify-completed'); + if (prReviewNotifyCompleted) { + prReviewNotifyCompleted.checked = prReviewCfg.notifyOnReviewCompleted !== false; + } + const prReviewApprovedAction = document.getElementById('pr-review-approved-action'); + if (prReviewApprovedAction) { + const value = String(prReviewCfg.approvedDeliveryAction || 'notify').trim().toLowerCase(); + prReviewApprovedAction.value = ['notify', 'paste', 'paste_and_notify', 'none'].includes(value) ? value : 'notify'; + } + const prReviewCommentedAction = document.getElementById('pr-review-commented-action'); + if (prReviewCommentedAction) { + const value = String(prReviewCfg.commentedDeliveryAction || 'notify').trim().toLowerCase(); + prReviewCommentedAction.value = ['notify', 'paste', 'paste_and_notify', 'none'].includes(value) ? value : 'notify'; + } + const prReviewNeedsFixAction = document.getElementById('pr-review-needs-fix-action'); + if (prReviewNeedsFixAction) { + const value = String(prReviewCfg.needsFixFeedbackAction || 'paste_and_notify').trim().toLowerCase(); + prReviewNeedsFixAction.value = ['notify', 'paste', 'paste_and_notify', 'none'].includes(value) ? value : 'paste_and_notify'; + } + const identityClaimName = document.getElementById('identity-claim-name'); if (identityClaimName) { const v = this.getIdentityClaimName(); @@ -24636,6 +25210,7 @@ class ClaudeOrchestrator { mode: 'mine', // mine | all kindFilter: 'all', // all | pr | worktree | session projectFilter: '', + repoScope: '', prioritizeActive: localStorage.getItem('queue-prioritize-active') === 'true', query: '', tasks: [], @@ -24674,6 +25249,11 @@ class ClaudeOrchestrator { reprompted: [] }; + const resolveQueueAgentId = () => { + const raw = String(localStorage.getItem('tasks-launch-agent') || 'claude').trim().toLowerCase(); + return raw === 'codex' ? 'codex' : 'claude'; + }; + const loadSnoozes = () => { try { const raw = localStorage.getItem('queue-snoozes'); @@ -24736,6 +25316,9 @@ class ClaudeOrchestrator { if (preset.projectFilter !== undefined) { state.projectFilter = String(preset.projectFilter || '').trim(); } + if (preset.repoScope !== undefined) { + state.repoScope = String(preset.repoScope || '').trim(); + } if (preset.prioritizeActive !== undefined) { state.prioritizeActive = !!preset.prioritizeActive; try { localStorage.setItem('queue-prioritize-active', state.prioritizeActive ? 'true' : 'false'); } catch {} @@ -24785,14 +25368,14 @@ class ClaudeOrchestrator {
- - + +
- + @@ -24813,7 +25396,7 @@ class ClaudeOrchestrator { - +
@@ -24908,6 +25491,12 @@ class ClaudeOrchestrator { } if (projectFilterEl) { projectFilterEl.value = String(state.projectFilter || ''); + const scopedRepos = String(state.repoScope || '').split(',').map((value) => String(value || '').trim()).filter(Boolean); + projectFilterEl.title = scopedRepos.length > 1 + ? 'Filter within the current workspace repos' + : scopedRepos.length === 1 + ? `Project filter within ${scopedRepos[0]}` + : 'Filter the current PR list by repo or project'; } const showPairingModal = async () => { @@ -25015,6 +25604,20 @@ class ClaudeOrchestrator { const setMode = (mode) => { state.mode = mode === 'all' ? 'all' : 'mine'; + const scopedRepos = String(state.repoScope || '').split(',').map((value) => String(value || '').trim()).filter(Boolean); + const repoCount = scopedRepos.length; + mineBtn.textContent = 'My PRs'; + mineBtn.title = repoCount > 1 + ? 'Only my PRs in the current workspace repos' + : repoCount === 1 + ? `Only my PRs in ${scopedRepos[0]}` + : 'Only PRs authored by me'; + allBtn.textContent = repoCount > 1 ? 'Workspace PRs' : repoCount === 1 ? 'This Repo' : 'Any Author'; + allBtn.title = repoCount > 1 + ? 'PRs from any author in the current workspace repos' + : repoCount === 1 + ? `PRs from any author in ${scopedRepos[0]}` + : 'PRs from any author'; mineBtn.classList.toggle('active', state.mode === 'mine'); allBtn.classList.toggle('active', state.mode === 'all'); }; @@ -25140,11 +25743,19 @@ class ClaudeOrchestrator { syncReviewControlsUI(); }); - autoReviewerBtn?.addEventListener('click', () => { + autoReviewerBtn?.addEventListener('click', async () => { state.autoReviewer = !state.autoReviewer; localStorage.setItem('queue-auto-reviewer', state.autoReviewer ? 'true' : 'false'); syncReviewControlsUI(); if (state.selectedId) renderDetail(getTaskById(state.selectedId)); + if (!state.autoReviewer) { + this.showToast?.('Auto Reviewer disabled', 'info'); + return; + } + const result = await triggerAutoReviewerForQueue({ notify: true, preferSelected: true }); + if (!result?.started && result?.reason === 'no_candidate') { + this.showToast?.('Auto Reviewer armed: it will start on the selected or next visible unreviewed PR', 'info'); + } }); autoFixerBtn?.addEventListener('click', () => { @@ -25323,6 +25934,7 @@ class ClaudeOrchestrator { const parts = raw.replace(/\\/g, '/').split('/').filter(Boolean); return parts[parts.length - 1] || raw; }; + const extractRepoSlug = (value) => this.extractGitHubRepoSlug(value); const updateProjectFilterOptions = () => { if (!projectFilterEl) return; @@ -25335,9 +25947,12 @@ class ClaudeOrchestrator { if (!label) continue; options.set(normalizeProjectKey(label), label); } - const sorted = Array.from(options.values()).sort((a, b) => String(a).localeCompare(String(b))); const current = normalizeProjectKey(state.projectFilter); - projectFilterEl.innerHTML = `` + sorted + if (current && state.projectFilter && !options.has(current)) { + options.set(current, state.projectFilter); + } + const sorted = Array.from(options.values()).sort((a, b) => String(a).localeCompare(String(b))); + projectFilterEl.innerHTML = `` + sorted .map((label) => ``) .join(''); if (current && options.has(current)) { @@ -25462,54 +26077,89 @@ class ClaudeOrchestrator { const buildActiveIndex = () => { const activeSessionIds = new Set(); + const openSessionIds = new Set(); const activeWorktreeIds = new Set(); + const openWorktreeIds = new Set(); const activeWorktreePaths = new Set(); + const openWorktreePaths = new Set(); const activeRepoNames = new Set(); + const openRepoNames = new Set(); const activeRepoSlugs = new Set(); + const openRepoSlugs = new Set(); for (const [sid, session] of this.sessions) { if (!this.isAgentSession(sid)) continue; const status = String(session?.status || '').trim().toLowerCase(); if (status === 'exited') continue; + openSessionIds.add(String(sid)); + + const worktreeId = String(session?.worktreeId || '').trim(); + if (worktreeId) openWorktreeIds.add(worktreeId); + + const worktreePath = String(session?.config?.cwd || '').trim(); + if (worktreePath) openWorktreePaths.add(worktreePath); + + const repoName = String(session?.repositoryName || '').trim(); + if (repoName) openRepoNames.add(normalizeProjectKey(repoName)); + + const repoRoot = String(session?.repositoryRoot || '').trim(); + const repoRootName = extractRepoName(repoRoot); + if (repoRootName) openRepoNames.add(normalizeProjectKey(repoRootName)); + + const repoSlug = String(session?.repositorySlug || '').trim(); + if (repoSlug) openRepoSlugs.add(normalizeProjectKey(repoSlug)); + const hasActivity = this.sessionActivity.get(sid) === 'active' || status === 'busy' || status === 'waiting'; if (!hasActivity) continue; activeSessionIds.add(String(sid)); - const worktreeId = String(session?.worktreeId || '').trim(); if (worktreeId) activeWorktreeIds.add(worktreeId); - const worktreePath = String(session?.config?.cwd || '').trim(); if (worktreePath) activeWorktreePaths.add(worktreePath); - const repoName = String(session?.repositoryName || '').trim(); if (repoName) activeRepoNames.add(normalizeProjectKey(repoName)); - const repoRoot = String(session?.repositoryRoot || '').trim(); - const repoRootName = extractRepoName(repoRoot); if (repoRootName) activeRepoNames.add(normalizeProjectKey(repoRootName)); - const repoSlug = String(session?.repositorySlug || '').trim(); if (repoSlug) activeRepoSlugs.add(normalizeProjectKey(repoSlug)); } - return { activeSessionIds, activeWorktreeIds, activeWorktreePaths, activeRepoNames, activeRepoSlugs }; + return { + activeSessionIds, + openSessionIds, + activeWorktreeIds, + openWorktreeIds, + activeWorktreePaths, + openWorktreePaths, + activeRepoNames, + openRepoNames, + activeRepoSlugs, + openRepoSlugs + }; }; const getActiveScoreForTask = (t, index) => { if (!index) return 0; if (t?.kind === 'session') { const sid = String(t?.sessionId || '').trim(); - return (sid && index.activeSessionIds.has(sid)) ? 3 : 0; + if (sid && index.activeSessionIds.has(sid)) return 5; + return (sid && index.openSessionIds.has(sid)) ? 4 : 0; } if (t?.kind === 'worktree') { const path = String(t?.worktreePath || '').trim(); - if (path && index.activeWorktreePaths.has(path)) return 2; + if (path && index.activeWorktreePaths.has(path)) return 4; + if (path && index.openWorktreePaths.has(path)) return 3; const worktreeId = String(t?.worktreeId || '').trim(); - if (worktreeId && index.activeWorktreeIds.has(worktreeId)) return 2; + if (worktreeId && index.activeWorktreeIds.has(worktreeId)) return 4; + if (worktreeId && index.openWorktreeIds.has(worktreeId)) return 3; return 0; } if (t?.kind === 'pr') { + const linkedSessionIds = this.getLinkedSessionIdsForPrTask(t?.id); + if (linkedSessionIds.some((sid) => index.activeSessionIds.has(String(sid || '').trim()))) return 6; + if (linkedSessionIds.some((sid) => index.openSessionIds.has(String(sid || '').trim()))) return 5; + const rec = (t?.record && typeof t.record === 'object') ? t.record : {}; const worktreeIds = [ rec.reviewerWorktreeId, @@ -25517,14 +26167,17 @@ class ClaudeOrchestrator { rec.recheckWorktreeId, rec.overnightWorktreeId ].map((v) => String(v || '').trim()).filter(Boolean); - if (worktreeIds.some((id) => index.activeWorktreeIds.has(id))) return 3; + if (worktreeIds.some((id) => index.activeWorktreeIds.has(id))) return 4; + if (worktreeIds.some((id) => index.openWorktreeIds.has(id))) return 3; const repoSlug = normalizeProjectKey(t?.repository || ''); - if (repoSlug && index.activeRepoSlugs.has(repoSlug)) return 2; + if (repoSlug && index.activeRepoSlugs.has(repoSlug)) return 3; + if (repoSlug && index.openRepoSlugs.has(repoSlug)) return 2; const repoName = normalizeProjectKey(extractRepoName(t?.repository || '')); const projectName = normalizeProjectKey(t?.project || ''); - if ((repoName && index.activeRepoNames.has(repoName)) || (projectName && index.activeRepoNames.has(projectName))) return 2; + if ((repoName && index.activeRepoNames.has(repoName)) || (projectName && index.activeRepoNames.has(projectName))) return 3; + if ((repoName && index.openRepoNames.has(repoName)) || (projectName && index.openRepoNames.has(projectName))) return 2; } return 0; }; @@ -25893,6 +26546,8 @@ class ClaudeOrchestrator { url.searchParams.set('mode', state.mode); url.searchParams.set('state', 'open'); url.searchParams.set('include', 'dependencySummary'); + const repoSlug = String(state.repoScope || '').trim() || extractRepoSlug(state.projectFilter); + if (repoSlug) url.searchParams.set('repo', repoSlug); const res = await fetch(url.toString()); const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error(data?.error || 'Failed to load queue'); @@ -25905,6 +26560,9 @@ class ClaudeOrchestrator { state.selectedId = ordered[0]?.id || null; } renderList(); + if (state.autoReviewer) { + Promise.resolve().then(() => triggerAutoReviewerForQueue({ notify: false, preferSelected: true })).catch(() => {}); + } refreshConflicts().catch(() => {}); }; @@ -25934,6 +26592,24 @@ class ClaudeOrchestrator { else this.taskRecords.delete(id); }; + const buildReviewSourcePatch = (task) => { + const t = task && typeof task === 'object' ? task : {}; + const patch = {}; + const sourceSessionId = this.resolvePreferredReviewSourceSession(t); + if (sourceSessionId) patch.reviewSourceSessionId = sourceSessionId; + + const session = sourceSessionId ? this.sessions.get(sourceSessionId) : null; + const worktreeId = String( + session?.worktreeId + || t?.record?.reviewSourceWorktreeId + || this.extractWorktreeLabel(String(t?.worktreePath || this.resolveWorktreePathForSession(sourceSessionId, session) || '').trim()) + || '' + ).trim(); + if (worktreeId) patch.reviewSourceWorktreeId = worktreeId; + + return patch; + }; + const stopReviewTimer = async ({ endedAtIso, reason = 'manual', nudge = false } = {}) => { const activeId = state.reviewTimer?.taskId; if (!activeId) return; @@ -26863,6 +27539,16 @@ class ClaudeOrchestrator { const reviewTestsCommand = String(reviewChecklist?.tests?.command || ''); const reviewManualDone = !!reviewChecklist?.manual?.done; const reviewManualSteps = String(reviewChecklist?.manual?.steps || ''); + const reviewerPostAction = String(record.reviewerPostAction || 'feedback').trim().toLowerCase() || 'feedback'; + const latestReviewSummary = String(record.latestReviewSummary || '').trim(); + const latestReviewBody = String(record.latestReviewBody || '').trim(); + const latestReviewOutcome = String(record.latestReviewOutcome || '').trim().toLowerCase(); + const latestReviewUser = String(record.latestReviewUser || '').trim(); + const latestReviewUrl = String(record.latestReviewUrl || '').trim(); + const latestReviewSubmittedAt = String(record.latestReviewSubmittedAt || '').trim(); + const latestReviewAgent = String(record.latestReviewAgent || record.reviewerAgent || '').trim(); + const latestReviewDeliveredAt = String(record.latestReviewDeliveredAt || '').trim(); + const latestReviewText = latestReviewSummary || latestReviewBody; const normalizePrUrl = (value) => { const raw = String(value || '').trim(); @@ -27047,19 +27733,41 @@ class ClaudeOrchestrator {
-
-
Outcome
-
- -
-
+
+
Outcome
+
+ +
+
+
+
Needs-fix action
+
+ +
+
+ + + +
+
Latest Review
+
+ ${latestReviewOutcome ? `${escapeHtml(latestReviewOutcome)}${latestReviewUser ? ` • ${escapeHtml(latestReviewUser)}` : ''}${latestReviewSubmittedAt ? ` • ${escapeHtml(latestReviewSubmittedAt)}` : ''}${latestReviewAgent ? ` • ${escapeHtml(latestReviewAgent)}` : ''}` : 'No saved review yet.'}
+ ${latestReviewDeliveredAt ? `
delivered: ${escapeHtml(latestReviewDeliveredAt)}
` : ''} +
+ + +
+
${escapeHtml(latestReviewText || 'No saved review summary yet.')}
@@ -27259,17 +27967,20 @@ class ClaudeOrchestrator { const openInspectorBtn = detailEl.querySelector('#queue-open-inspector'); const openConsoleBtn = detailEl.querySelector('#queue-open-console'); const spawnOvernightBtn = detailEl.querySelector('#queue-spawn-overnight'); - const spawnReviewerBtn = detailEl.querySelector('#queue-spawn-reviewer'); + const spawnReviewerBtn = detailEl.querySelector('#queue-review-pr') || detailEl.querySelector('#queue-spawn-reviewer'); const spawnFixerBtn = detailEl.querySelector('#queue-spawn-fixer'); const repromptBtn = detailEl.querySelector('#queue-reprompt'); - const spawnRecheckBtn = detailEl.querySelector('#queue-spawn-recheck'); - const timerStartBtn = detailEl.querySelector('#queue-review-timer-start'); + const spawnRecheckBtn = detailEl.querySelector('#queue-spawn-recheck'); + const openLatestReviewBtn = detailEl.querySelector('#queue-open-latest-review'); + const pasteLatestReviewBtn = detailEl.querySelector('#queue-paste-latest-review'); + const timerStartBtn = detailEl.querySelector('#queue-review-timer-start'); const timerStopBtn = detailEl.querySelector('#queue-review-timer-stop'); - const notesEl = detailEl.querySelector('#queue-notes'); + const notesEl = detailEl.querySelector('#queue-notes'); const reviewTestsDoneEl = detailEl.querySelector('#queue-review-tests-done'); const reviewTestsCommandEl = detailEl.querySelector('#queue-review-tests-command'); const reviewManualDoneEl = detailEl.querySelector('#queue-review-manual-done'); const reviewManualStepsEl = detailEl.querySelector('#queue-review-manual-steps'); + const reviewPostActionEl = detailEl.querySelector('#queue-review-post-action'); const snoozeAutoBtn = detailEl.querySelector('#queue-snooze-auto'); const snooze15Btn = detailEl.querySelector('#queue-snooze-15m'); const snooze1hBtn = detailEl.querySelector('#queue-snooze-1h'); @@ -28235,6 +28946,30 @@ class ClaudeOrchestrator { } }); + openLatestReviewBtn?.addEventListener('click', () => { + this.openLatestReviewForTask(getTaskById(t.id) || t); + }); + + pasteLatestReviewBtn?.addEventListener('click', () => { + this.pasteLatestReviewToTaskSession(getTaskById(t.id) || t); + }); + + reviewPostActionEl?.addEventListener('change', async () => { + try { + const value = String(reviewPostActionEl.value || '').trim().toLowerCase(); + reviewPostActionEl.disabled = true; + const patch = { reviewerPostAction: value || 'feedback' }; + const rec = await upsertRecord(t.id, patch); + updateTaskRecordInState(t.id, rec); + await maybeAutoSpawnFixer({ ...(t || {}), record: rec }); + renderDetail(getTaskById(t.id)); + } catch (e) { + this.showToast(String(e?.message || e), 'error'); + } finally { + if (reviewPostActionEl) reviewPostActionEl.disabled = false; + } + }); + const runGitHubReview = async ({ action, body } = {}) => { const res = await fetch('/api/prs/review', { method: 'POST', @@ -28302,10 +29037,11 @@ class ClaudeOrchestrator { nudged = true; } - const patch = { reviewed: true, reviewOutcome: 'needs_fix', reviewEndedAt: new Date().toISOString(), claimedBy: null, claimedAt: null }; - const rec = await upsertRecord(t.id, patch); - updateTaskRecordInState(t.id, rec); - await maybeApplyTrelloNeedsFixLabel({ taskId: t.id, outcome: 'needs_fix', notes: body }).catch(() => {}); + const patch = { reviewed: true, reviewOutcome: 'needs_fix', reviewEndedAt: new Date().toISOString(), claimedBy: null, claimedAt: null }; + const rec = await upsertRecord(t.id, patch); + updateTaskRecordInState(t.id, rec); + await maybeAutoSpawnFixer({ ...(t || {}), record: rec }); + await maybeApplyTrelloNeedsFixLabel({ taskId: t.id, outcome: 'needs_fix', notes: body }).catch(() => {}); await fetchTasks().catch(() => {}); renderDetail(getTaskById(t.id)); @@ -28348,7 +29084,7 @@ class ClaudeOrchestrator { spawnOvernightBtn?.addEventListener('click', async () => { try { spawnOvernightBtn.disabled = true; - const info = await this.spawnOvernightRunnerForPRTask(t, { tier: 4, agentId: 'claude', mode: 'fresh', yolo: true }); + const info = await this.spawnOvernightRunnerForPRTask(t, { tier: 4, agentId: resolveQueueAgentId(), mode: 'fresh', yolo: true }); if (info) { const rec = await upsertRecord(t.id, { overnightSpawnedAt: new Date().toISOString(), @@ -28368,9 +29104,9 @@ class ClaudeOrchestrator { spawnReviewerBtn?.addEventListener('click', async () => { try { spawnReviewerBtn.disabled = true; - const info = await this.spawnReviewAgentForPRTask(t, { tier: 3, agentId: 'claude', mode: 'fresh', yolo: true }); + const info = await this.spawnReviewAgentForPRTask(t, { tier: 3, agentId: resolveQueueAgentId(), mode: 'fresh', yolo: true }); if (info) { - const patch = { reviewerSpawnedAt: new Date().toISOString() }; + const patch = { reviewerSpawnedAt: new Date().toISOString(), ...buildReviewSourcePatch(t) }; if (info.worktreeId) patch.reviewerWorktreeId = info.worktreeId; const rec = await upsertRecord(t.id, patch); updateTaskRecordInState(t.id, rec); @@ -28387,7 +29123,7 @@ class ClaudeOrchestrator { spawnFixerBtn?.addEventListener('click', async () => { try { spawnFixerBtn.disabled = true; - const info = await this.spawnFixAgentForPRTask(t, { tier: 2, agentId: 'claude', mode: 'fresh', yolo: true, notes: String(notesEl?.value || '') }); + const info = await this.spawnFixAgentForPRTask(t, { tier: 2, agentId: resolveQueueAgentId(), mode: 'fresh', yolo: true, notes: String(notesEl?.value || '') }); if (info) { const rec = await upsertRecord(t.id, { fixerSpawnedAt: new Date().toISOString(), @@ -28413,7 +29149,7 @@ class ClaudeOrchestrator { const existingFixerId = String(t?.record?.fixerWorktreeId || '').trim(); const info = await this.spawnFixAgentForPRTask(t, { tier: 2, - agentId: 'claude', + agentId: resolveQueueAgentId(), mode: 'fresh', yolo: true, notes, @@ -28441,7 +29177,7 @@ class ClaudeOrchestrator { spawnRecheckBtn?.addEventListener('click', async () => { try { spawnRecheckBtn.disabled = true; - const info = await this.spawnReviewAgentForPRTask(t, { tier: 3, agentId: 'claude', mode: 'fresh', yolo: true }); + const info = await this.spawnReviewAgentForPRTask(t, { tier: 3, agentId: resolveQueueAgentId(), mode: 'fresh', yolo: true }); if (info) { const rec = await upsertRecord(t.id, { recheckSpawnedAt: new Date().toISOString(), @@ -28517,6 +29253,7 @@ class ClaudeOrchestrator { updateTaskRecordInState(t.id, rec); if (value === 'needs_fix') { await maybeApplyTrelloNeedsFixLabel({ taskId: t.id, outcome: value, notes: String(notesEl?.value || '') }); + await maybeAutoSpawnFixer({ ...(t || {}), record: rec }); } await fetchTasks(); renderDetail(getTaskById(t.id)); @@ -28570,76 +29307,101 @@ class ClaudeOrchestrator { } }; - const maybeAutoSpawnReviewer = async (t) => { - if (!state.autoReviewer) return; - const task = t || {}; - if (task.kind !== 'pr') return; + const getAutoReviewerEligibility = (task) => { + const t = task || {}; + if (!state.autoReviewer) return { ok: false, reason: 'disabled' }; + if (t.kind !== 'pr') return { ok: false, reason: 'not_pr' }; + if (t?.record?.reviewedAt) return { ok: false, reason: 'reviewed' }; + if (t?.record?.reviewerSpawnedAt) return { ok: false, reason: 'already_started' }; + if (state.reviewerSpawning?.has?.(t.id)) return { ok: false, reason: 'starting' }; + return { ok: true, reason: '' }; + }; + + const getAutoReviewerCandidate = ({ preferSelected = true } = {}) => { + if (preferSelected && state.selectedId) { + const selected = getTaskById(state.selectedId); + if (getAutoReviewerEligibility(selected).ok) return selected; + } + const ordered = getOrderedTasks(getFilteredTasks()); + return ordered.find((task) => getAutoReviewerEligibility(task).ok) || null; + }; + + const maybeAutoSpawnReviewer = async (task, { silent = true } = {}) => { + const eligibility = getAutoReviewerEligibility(task); + if (!eligibility.ok) return false; + const nextTask = task || {}; + + state.reviewerSpawning.add(nextTask.id); + + try { + const info = await this.spawnReviewAgentForPRTask(nextTask, { tier: 3, agentId: resolveQueueAgentId(), mode: 'fresh', yolo: true }); + if (!info) return false; + const patch = { reviewerSpawnedAt: new Date().toISOString(), ...buildReviewSourcePatch(nextTask) }; + if (info?.worktreeId) patch.reviewerWorktreeId = info.worktreeId; + const rec = await upsertRecord(nextTask.id, patch); + updateTaskRecordInState(nextTask.id, rec); + renderList(); + renderDetail(getTaskById(nextTask.id)); + return true; + } catch (e) { + if (!silent) this.showToast?.(String(e?.message || e), 'error'); + else console.warn('Auto reviewer spawn failed:', e); + return false; + } finally { + state.reviewerSpawning.delete(nextTask.id); + } + }; + + const triggerAutoReviewerForQueue = async ({ notify = false, preferSelected = true } = {}) => { + if (!state.autoReviewer) return { started: false, reason: 'disabled' }; + const candidate = getAutoReviewerCandidate({ preferSelected }); + if (!candidate) return { started: false, reason: 'no_candidate' }; + const started = await maybeAutoSpawnReviewer(candidate, { silent: !notify }); + return { started, taskId: candidate?.id || '', reason: started ? '' : 'failed' }; + }; + + const parseIsoMaybe = (v) => { + const ms = Date.parse(String(v || '')); + return Number.isFinite(ms) ? ms : 0; + }; + + const maybeAutoSpawnFixer = async (t) => { + const task = t || {}; + if (task.kind !== 'pr') return; + const rec = (task?.record && typeof task.record === 'object') ? task.record : {}; + const taskAction = String(rec?.reviewerPostAction || '').trim().toLowerCase(); + if (!state.autoFixer && taskAction !== 'auto_fix') return; + const shouldAutoFix = taskAction ? taskAction === 'auto_fix' : !!state.autoFixer; + if (!shouldAutoFix) return; const tier = Number(task?.record?.tier); if (tier !== 3) return; - if (task?.record?.reviewedAt) return; - if (task?.record?.reviewerSpawnedAt) return; + if (rec?.fixerSpawnedAt) return; + const outcome = String(rec?.reviewOutcome || '').trim().toLowerCase(); + if (outcome !== 'needs_fix') return; + const notes = String(rec?.notes || '').trim(); + if (!notes) return; - if (state.reviewerSpawning?.has?.(task.id)) return; - state.reviewerSpawning.add(task.id); + if (state.fixerSpawning?.has?.(task.id)) return; + state.fixerSpawning.add(task.id); try { - const info = await this.spawnReviewAgentForPRTask(task, { tier: 3, agentId: 'claude', mode: 'fresh', yolo: true }); + const info = await this.spawnFixAgentForPRTask(task, { tier: 2, agentId: resolveQueueAgentId(), mode: 'fresh', yolo: true, notes }); if (!info) return; - const patch = { reviewerSpawnedAt: new Date().toISOString() }; - if (info?.worktreeId) patch.reviewerWorktreeId = info.worktreeId; - const rec = await upsertRecord(task.id, patch); - updateTaskRecordInState(task.id, rec); + const patch = { fixerSpawnedAt: new Date().toISOString() }; + if (info?.worktreeId) patch.fixerWorktreeId = info.worktreeId; + const next = await upsertRecord(task.id, patch); + updateTaskRecordInState(task.id, next); renderList(); renderDetail(getTaskById(task.id)); } catch (e) { - // best-effort; keep it silent unless it was user-initiated - console.warn('Auto reviewer spawn failed:', e); + console.warn('Auto fixer spawn failed:', e); } finally { - state.reviewerSpawning.delete(task.id); + state.fixerSpawning.delete(task.id); } }; - const parseIsoMaybe = (v) => { - const ms = Date.parse(String(v || '')); - return Number.isFinite(ms) ? ms : 0; - }; - - const maybeAutoSpawnFixer = async (t) => { - if (!state.autoFixer) return; - const task = t || {}; - if (task.kind !== 'pr') return; - - const tier = Number(task?.record?.tier); - if (tier !== 3) return; - - const rec = (task?.record && typeof task.record === 'object') ? task.record : {}; - if (rec?.fixerSpawnedAt) return; - const outcome = String(rec?.reviewOutcome || '').trim().toLowerCase(); - if (outcome !== 'needs_fix') return; - const notes = String(rec?.notes || '').trim(); - if (!notes) return; - - if (state.fixerSpawning?.has?.(task.id)) return; - state.fixerSpawning.add(task.id); - - try { - const info = await this.spawnFixAgentForPRTask(task, { tier: 2, agentId: 'claude', mode: 'fresh', yolo: true, notes }); - if (!info) return; - const patch = { fixerSpawnedAt: new Date().toISOString() }; - if (info?.worktreeId) patch.fixerWorktreeId = info.worktreeId; - const next = await upsertRecord(task.id, patch); - updateTaskRecordInState(task.id, next); - renderList(); - renderDetail(getTaskById(task.id)); - } catch (e) { - console.warn('Auto fixer spawn failed:', e); - } finally { - state.fixerSpawning.delete(task.id); - } - }; - const maybeAutoSpawnRecheck = async (t) => { if (!state.autoRecheck) return; const task = t || {}; @@ -28662,7 +29424,7 @@ class ClaudeOrchestrator { state.recheckSpawning.add(task.id); try { - const info = await this.spawnReviewAgentForPRTask(task, { tier: 3, agentId: 'claude', mode: 'fresh', yolo: true }); + const info = await this.spawnReviewAgentForPRTask(task, { tier: 3, agentId: resolveQueueAgentId(), mode: 'fresh', yolo: true }); if (!info) return; const patch = { recheckSpawnedAt: new Date().toISOString() }; if (info?.worktreeId) patch.recheckWorktreeId = info.worktreeId; @@ -28721,9 +29483,14 @@ class ClaudeOrchestrator { if (state.selectedId) renderDetail(getTaskById(state.selectedId)); }); - projectFilterEl?.addEventListener('change', () => { + projectFilterEl?.addEventListener('change', async () => { state.projectFilter = String(projectFilterEl.value || ''); - applyFiltersAndMaybeClampSelection(); + try { + await fetchTasks(); + if (state.selectedId) renderDetail(getTaskById(state.selectedId)); + } catch (e) { + this.showToast(String(e?.message || e), 'error'); + } }); refreshBtn.addEventListener('click', async () => { @@ -29137,9 +29904,9 @@ class ClaudeOrchestrator { return false; } try { - const info = await this.spawnReviewAgentForPRTask(t, { tier: 3, agentId: 'claude', mode: 'fresh', yolo: true }); + const info = await this.spawnReviewAgentForPRTask(t, { tier: 3, agentId: resolveQueueAgentId(), mode: 'fresh', yolo: true }); if (info) { - const patch = { reviewerSpawnedAt: new Date().toISOString() }; + const patch = { reviewerSpawnedAt: new Date().toISOString(), ...buildReviewSourcePatch(t) }; if (info.worktreeId) patch.reviewerWorktreeId = info.worktreeId; const rec = await upsertRecord(t.id, patch); updateTaskRecordInState(t.id, rec); @@ -29167,7 +29934,7 @@ class ClaudeOrchestrator { return false; } try { - const info = await this.spawnFixAgentForPRTask(t, { tier: 2, agentId: 'claude', mode: 'fresh', yolo: true, notes: String(getTaskById(t.id)?.record?.notes || '') }); + const info = await this.spawnFixAgentForPRTask(t, { tier: 2, agentId: resolveQueueAgentId(), mode: 'fresh', yolo: true, notes: String(getTaskById(t.id)?.record?.notes || '') }); if (info) { const rec = await upsertRecord(t.id, { fixerSpawnedAt: new Date().toISOString(), @@ -29198,7 +29965,7 @@ class ClaudeOrchestrator { return false; } try { - const info = await this.spawnReviewAgentForPRTask(t, { tier: 3, agentId: 'claude', mode: 'fresh', yolo: true }); + const info = await this.spawnReviewAgentForPRTask(t, { tier: 3, agentId: resolveQueueAgentId(), mode: 'fresh', yolo: true }); if (info) { const rec = await upsertRecord(t.id, { recheckSpawnedAt: new Date().toISOString(), @@ -29229,7 +29996,7 @@ class ClaudeOrchestrator { return false; } try { - const info = await this.spawnOvernightRunnerForPRTask(t, { tier: 4, agentId: 'claude', mode: 'fresh', yolo: true }); + const info = await this.spawnOvernightRunnerForPRTask(t, { tier: 4, agentId: resolveQueueAgentId(), mode: 'fresh', yolo: true }); if (info) { const rec = await upsertRecord(t.id, { overnightSpawnedAt: new Date().toISOString(), @@ -29592,8 +30359,10 @@ class ClaudeOrchestrator { const rec = await upsertRecord(t.id, patch); updateTaskRecordInState(t.id, rec); + const nextTask = { ...(t || {}), record: rec }; if (value === 'needs_fix') { await maybeApplyTrelloNeedsFixLabel({ taskId: t.id, outcome: value, notes: String(getTaskById(t.id)?.record?.notes || '') }); + await maybeAutoSpawnFixer(nextTask); } await fetchTasks().catch(() => {}); @@ -29821,6 +30590,7 @@ class ClaudeOrchestrator { const patch = { reviewed: true, reviewOutcome: 'needs_fix', reviewEndedAt: new Date().toISOString(), claimedBy: null, claimedAt: null }; const rec = await upsertRecord(t.id, patch); updateTaskRecordInState(t.id, rec); + await maybeAutoSpawnFixer({ ...(t || {}), record: rec }); await maybeApplyTrelloNeedsFixLabel({ taskId: t.id, outcome: 'needs_fix', notes: reviewBody }).catch(() => {}); await fetchTasks().catch(() => {}); @@ -29874,11 +30644,22 @@ class ClaudeOrchestrator { await fetchTasks().catch(() => {}); renderDetail(getTaskById(t.id)); return true; - } catch (err) { - this.showToast?.(String(err?.message || err), 'error'); - return false; - } - }, + } catch (err) { + this.showToast?.(String(err?.message || err), 'error'); + return false; + } + }, + handleAutomationEvent: (payload = {}) => { + const prId = String(payload?.prId || '').trim(); + const recordPatch = (payload?.recordPatch && typeof payload.recordPatch === 'object') ? payload.recordPatch : null; + if (!prId || !recordPatch) return false; + const existing = getTaskById(prId); + if (!existing) return false; + updateTaskRecordInState(prId, recordPatch); + renderList(); + if (state.selectedId === prId) renderDetail(getTaskById(prId)); + return true; + }, next: () => navigate(1), prev: () => navigate(-1) }; @@ -32402,12 +33183,12 @@ class ClaudeOrchestrator { return { agentId: 'claude', mode: m, flags }; } - async spawnReviewAgentForPRTask(prTask, { tier = 3, agentId = 'claude', mode = 'fresh', yolo = true, worktreeId = null } = {}) { + async spawnReviewAgentForPRTask(prTask, { tier = 3, agentId = 'claude', mode = 'fresh', yolo = true, worktreeId = null } = {}) { const t = prTask || {}; if (t.kind !== 'pr') return; const url = String(t.url || '').trim(); - const repoSlug = String(t.repository || '').trim(); + const repoSlug = this.getRepositorySlugForPRTask(t); if (!repoSlug) { this.showToast('PR task is missing repository slug', 'error'); return; @@ -32515,7 +33296,7 @@ class ClaudeOrchestrator { if (t.kind !== 'pr') return null; const url = String(t.url || '').trim(); - const repoSlug = String(t.repository || '').trim(); + const repoSlug = this.getRepositorySlugForPRTask(t); if (!repoSlug) { this.showToast('PR task is missing repository slug', 'error'); return null; @@ -32629,7 +33410,7 @@ class ClaudeOrchestrator { if (t.kind !== 'pr') return null; const url = String(t.url || '').trim(); - const repoSlug = String(t.repository || '').trim(); + const repoSlug = this.getRepositorySlugForPRTask(t); if (!repoSlug) { this.showToast('PR task is missing repository slug', 'error'); return null; diff --git a/client/index.html b/client/index.html index e3c58b56..fa483e8d 100644 --- a/client/index.html +++ b/client/index.html @@ -821,6 +821,81 @@

PR Merge Automation

+
+

PR Review Automation

+
+ +

Allows webhook/poll review tracking and background reviewer launches when configured.

+
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +

Per-task “Needs-fix action” in Queue still overrides this when set to auto-fix.

+
+
+

Tasks Launch

diff --git a/client/styles.css b/client/styles.css index 62b1bf22..210b7e81 100644 --- a/client/styles.css +++ b/client/styles.css @@ -6037,6 +6037,25 @@ body.dependency-onboarding-active #dependency-setup-modal { border-color: var(--accent-primary-hover); } +.control-btn.pr-review-status-btn.pending { + background: color-mix(in srgb, var(--accent-warning) 18%, var(--bg-tertiary)); + border-color: color-mix(in srgb, var(--accent-warning) 50%, var(--border-primary)); + color: var(--accent-warning); +} + +.control-btn.pr-review-status-btn.ready, +.control-btn.pr-review-paste-btn { + background: color-mix(in srgb, var(--accent-primary) 14%, var(--bg-tertiary)); + border-color: color-mix(in srgb, var(--accent-primary) 45%, var(--border-primary)); + color: var(--accent-primary); +} + +.control-btn.pr-review-status-btn.ready:hover, +.control-btn.pr-review-paste-btn:hover { + background: color-mix(in srgb, var(--accent-primary) 22%, var(--bg-tertiary)); + border-color: var(--accent-primary); +} + .terminal-controls { position: relative; } diff --git a/server/commandRegistry.js b/server/commandRegistry.js index 0d36c62c..7ed5ba60 100644 --- a/server/commandRegistry.js +++ b/server/commandRegistry.js @@ -725,6 +725,17 @@ class CommandRegistry { } }); + this.register('queue-review-pr', { + category: 'process', + description: 'Start a PR review agent for the selected Queue PR (Tier 3 reviewer)', + params: [], + examples: [], + handler: (params, { io }) => { + io.emit('commander-action', { action: 'queue-review-pr' }); + return { message: 'Queue: review PR' }; + } + }); + this.register('queue-spawn-reviewer', { category: 'process', description: 'Spawn a reviewer agent for the selected Queue PR (Tier 3 reviewer)', diff --git a/server/index.js b/server/index.js index b444dd5c..8d8ddea7 100644 --- a/server/index.js +++ b/server/index.js @@ -3645,7 +3645,8 @@ app.post('/api/webhooks/github', async (req, res) => { reviewState: review.state || '', reviewBody: review.body || '', reviewUser: review.user?.login || '', - url: pr.html_url || pr.url || '' + url: pr.html_url || pr.url || '', + reviewUrl: review.html_url || review.url || pr.html_url || pr.url || '' }); return res.json({ ok: true, event, verified: sig.verified, action, result }); } diff --git a/server/prReviewAutomationService.js b/server/prReviewAutomationService.js index 82b4e2e4..faeb7101 100644 --- a/server/prReviewAutomationService.js +++ b/server/prReviewAutomationService.js @@ -2,6 +2,7 @@ const path = require('path'); const winston = require('winston'); +const { collectDiagnostics } = require('./diagnosticsService'); const logger = winston.createLogger({ level: process.env.LOG_LEVEL || 'info', @@ -12,6 +13,61 @@ const logger = winston.createLogger({ }); const CONFIG_PATH = 'global.ui.tasks.automations.prReview'; +const AGENT_CLI_CACHE_TTL_MS = 30_000; + +const normalizeOptionalCliValue = (value) => { + const raw = String(value || '').trim(); + if (!raw) return undefined; + const normalized = raw.toLowerCase(); + if (normalized === 'default' || normalized === 'latest') return undefined; + return raw; +}; + +const normalizeReviewerMode = (value) => { + const raw = String(value || '').trim().toLowerCase(); + if (raw === 'resume') { + // Automated reviewer/fixer spawns do not have a resume id, so picker-style + // resume would block the workflow instead of launching directly. + return 'continue'; + } + return raw === 'continue' || raw === 'fresh' ? raw : 'fresh'; +}; + +const normalizeReviewerPostAction = (v) => { + const s = String(v || '').trim().toLowerCase(); + return s === 'auto_fix' || s === 'auto-fix' ? 'auto_fix' : 'feedback'; +}; + +const normalizeDeliveryAction = (value, fallback = 'notify') => { + const raw = String(value || '').trim().toLowerCase(); + if (!raw) return fallback; + if (raw === 'paste_and_notify' || raw === 'paste-and-notify') return 'paste_and_notify'; + if (raw === 'paste' || raw === 'notify' || raw === 'none') return raw; + return fallback; +}; + +const normalizeAgentId = (value) => { + const raw = String(value || '').trim().toLowerCase(); + return raw === 'codex' ? 'codex' : 'claude'; +}; + +const summarizeReviewBody = (value) => { + const lines = String(value || '') + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); + + if (!lines.length) return ''; + + const picked = []; + for (const line of lines) { + if (picked.join('\n').length >= 1200) break; + picked.push(line); + if (picked.length >= 8) break; + } + + return picked.join('\n').slice(0, 1200); +}; const DEFAULT_CONFIG = { enabled: false, @@ -19,10 +75,23 @@ const DEFAULT_CONFIG = { pollMs: 60_000, webhookEnabled: true, reviewerAgent: 'claude', + reviewerMode: 'fresh', + reviewerProvider: 'anthropic', + reviewerClaudeModel: '', + reviewerSkipPermissions: true, + reviewerCodexModel: '', + reviewerCodexReasoning: '', + reviewerCodexVerbosity: '', + reviewerCodexFlags: ['yolo'], reviewerTier: 3, autoSpawnReviewer: true, autoFeedbackToAuthor: true, autoSpawnFixer: false, + notifyOnReviewerSpawn: true, + notifyOnReviewCompleted: true, + approvedDeliveryAction: 'notify', + commentedDeliveryAction: 'notify', + needsFixFeedbackAction: 'paste_and_notify', maxConcurrentReviewers: 3, repos: [] }; @@ -36,11 +105,17 @@ class PrReviewAutomationService { this.workspaceManager = deps.workspaceManager || null; this.ensureWorkspaceMixedWorktree = deps.ensureWorkspaceMixedWorktree || null; this.io = deps.io || null; + this.collectDiagnostics = deps.collectDiagnostics || collectDiagnostics; this.lastPollAt = null; this.activeReviewers = new Map(); this.processedPrKeys = new Set(); + this.activeFixers = new Map(); this.pollTimer = null; + this.agentCliAvailabilityCache = { + at: 0, + value: null + }; } static getInstance(deps = {}) { @@ -80,6 +155,226 @@ class PrReviewAutomationService { return next; } + _resolveReviewerConfig(cfg = {}) { + const rawAgent = String(cfg.reviewerAgent || 'claude').trim().toLowerCase(); + const agentId = rawAgent === 'codex' ? 'codex' : 'claude'; + const mode = normalizeReviewerMode(cfg.reviewerMode || 'fresh'); + + if (agentId === 'codex') { + const model = normalizeOptionalCliValue(cfg.reviewerCodexModel); + + const reasoningRaw = String(cfg.reviewerCodexReasoning || '').trim().toLowerCase(); + const reasoning = (reasoningRaw === 'low' || reasoningRaw === 'medium' || reasoningRaw === 'high' || reasoningRaw === 'xhigh') + ? reasoningRaw + : undefined; + + const verbosityRaw = String(cfg.reviewerCodexVerbosity || '').trim().toLowerCase(); + const verbosity = (verbosityRaw === 'low' || verbosityRaw === 'medium' || verbosityRaw === 'high') + ? verbosityRaw + : undefined; + + const providedFlags = Array.isArray(cfg.reviewerCodexFlags) ? cfg.reviewerCodexFlags : []; + const validFlags = new Set(['yolo', 'workspaceWrite', 'readOnly', 'neverAsk', 'askOnRequest']); + const flags = providedFlags + .map((f) => String(f || '').trim()) + .filter((f) => validFlags.has(f)); + + return { + agentId, + mode, + model, + reasoning, + verbosity, + flags: flags.length ? [...new Set(flags)] : ['yolo'] + }; + } + + const provider = String(cfg.reviewerProvider || 'anthropic').trim() || 'anthropic'; + const model = normalizeOptionalCliValue(cfg.reviewerClaudeModel); + const reviewerSkipPermissions = cfg.reviewerSkipPermissions === undefined ? true : !!cfg.reviewerSkipPermissions; + + return { + agentId, + mode, + provider, + model, + flags: reviewerSkipPermissions ? ['skipPermissions'] : [] + }; + } + + async _getInstalledAgentCliAvailability() { + const now = Date.now(); + if (this.agentCliAvailabilityCache.value && (now - this.agentCliAvailabilityCache.at) < AGENT_CLI_CACHE_TTL_MS) { + return this.agentCliAvailabilityCache.value; + } + + const diagnostics = await this.collectDiagnostics(); + const tools = Array.isArray(diagnostics?.tools) ? diagnostics.tools : []; + const availability = { + claude: tools.some((tool) => tool?.id === 'claude' && !!tool?.ok), + codex: tools.some((tool) => tool?.id === 'codex' && !!tool?.ok) + }; + + this.agentCliAvailabilityCache = { + at: now, + value: availability + }; + return availability; + } + + async _resolveRunnableReviewerConfig(cfg = {}) { + const requested = this._resolveReviewerConfig(cfg); + try { + const availability = await this._getInstalledAgentCliAvailability(); + if (availability[requested.agentId]) { + return requested; + } + + const fallbackAgentId = requested.agentId === 'codex' ? 'claude' : 'codex'; + if (availability[fallbackAgentId]) { + logger.warn('Requested reviewer agent CLI unavailable; falling back to installed alternative', { + requestedAgent: requested.agentId, + fallbackAgent: fallbackAgentId + }); + return this._resolveReviewerConfig({ ...cfg, reviewerAgent: fallbackAgentId }); + } + + logger.warn('No supported reviewer agent CLI is installed', { + requestedAgent: requested.agentId, + availability + }); + return null; + } catch (error) { + logger.warn('Failed to detect reviewer agent CLI availability; using requested agent config', { + requestedAgent: requested.agentId, + error: error.message + }); + return requested; + } + } + + _resolvePostReviewAction(prId, cfg = {}) { + const record = this.taskRecordService?.get?.(prId) || null; + if (record?.reviewerPostAction) { + return normalizeReviewerPostAction(record.reviewerPostAction); + } + + if (cfg.autoFeedbackToAuthor) return 'feedback'; + if (cfg.autoSpawnFixer) return 'auto_fix'; + + return 'feedback'; + } + + _resolveOutcomeDeliveryAction(outcome, cfg = {}) { + const key = String(outcome || '').trim().toLowerCase(); + if (key === 'approved') { + return normalizeDeliveryAction(cfg.approvedDeliveryAction, 'notify'); + } + if (key === 'commented') { + return normalizeDeliveryAction(cfg.commentedDeliveryAction, 'notify'); + } + return normalizeDeliveryAction(cfg.needsFixFeedbackAction, 'paste_and_notify'); + } + + _inferReviewerAgent(prId, reviewInfo = {}, cfg = {}) { + const active = this.activeReviewers.get(prId) || null; + const fromSession = String(active?.sessionId || '').trim().toLowerCase(); + if (fromSession.endsWith('-codex')) return 'codex'; + if (fromSession.endsWith('-claude')) return 'claude'; + + const record = this.taskRecordService?.get?.(prId) || null; + const stored = String(record?.reviewerAgent || '').trim().toLowerCase(); + if (stored === 'codex' || stored === 'claude') return stored; + + const latest = String(reviewInfo?.latestReviewAgent || '').trim().toLowerCase(); + if (latest === 'codex' || latest === 'claude') return latest; + + return normalizeAgentId(cfg.reviewerAgent || 'claude'); + } + + _buildReviewSnapshot(prId, reviewInfo = {}, outcome, cfg = {}) { + const reviewBody = String(reviewInfo?.reviewBody || '').trim(); + const reviewSummary = summarizeReviewBody(reviewBody) || '(No detailed comments)'; + return { + latestReviewBody: reviewBody || null, + latestReviewSummary: reviewSummary, + latestReviewOutcome: outcome || null, + latestReviewUser: String(reviewInfo?.reviewUser || '').trim() || null, + latestReviewUrl: String(reviewInfo?.reviewUrl || reviewInfo?.url || '').trim() || null, + latestReviewSubmittedAt: reviewInfo?.reviewSubmittedAt || new Date().toISOString(), + latestReviewAgent: this._inferReviewerAgent(prId, reviewInfo, cfg) + }; + } + + _buildReviewFeedbackMessage(prId, reviewInfo = {}, outcome) { + const { number } = this._getPrIdentityFromId(prId); + const normalizedOutcome = String(outcome || '').trim().toLowerCase(); + const outcomeLabel = normalizedOutcome === 'approved' + ? 'APPROVED' + : normalizedOutcome === 'commented' + ? 'COMMENTED' + : 'CHANGES REQUESTED'; + const reviewUrl = String(reviewInfo?.reviewUrl || reviewInfo?.url || '').trim(); + const summary = String(reviewInfo?.reviewSummary || summarizeReviewBody(reviewInfo?.reviewBody || '') || '(No detailed comments)').trim(); + + return [ + '', + '--- PR Review Update ---', + `PR #${reviewInfo.number || number || '?'} reviewed by ${reviewInfo.reviewUser || 'AI reviewer'}.`, + `Outcome: ${outcomeLabel}`, + reviewInfo.reviewAgent ? `Reviewer agent: ${String(reviewInfo.reviewAgent).trim()}` : '', + reviewUrl ? `GitHub: ${reviewUrl}` : '', + '', + 'Summary:', + summary, + '', + normalizedOutcome === 'needs_fix' + ? 'Please address the feedback and push updated commits.' + : normalizedOutcome === 'approved' + ? 'The PR review approved the current changes.' + : 'The reviewer left comments but did not block the PR.', + '--- End PR Review Update ---', + '' + ].filter(Boolean).join('\n'); + } + + _getPrIdentityFromId(prId) { + const raw = String(prId || '').trim(); + const match = raw.match(/^pr:([^/]+)\/([^#]+)#(\d+)$/); + if (!match) return {}; + return { + owner: String(match[1] || '').trim(), + repo: String(match[2] || '').trim(), + number: Number(match[3]) || 0 + }; + } + + _buildFixPrompt(pr, reviewInfo, cfg) { + const reviewText = String(reviewInfo?.reviewBody || reviewInfo?.notes || '').trim() || '(no review body provided)'; + return [ + `You are a fixer agent for PR ${pr.number} in ${pr.owner}/${pr.repo}.`, + pr.title ? `PR title: ${pr.title}` : '', + pr.author ? `PR author: ${pr.author}` : '', + pr.url ? `PR URL: ${pr.url}` : '', + '', + 'You should implement only the changes requested in the latest review.', + '', + 'Reviewer feedback:', + reviewText, + '', + `Use \`gh pr checkout ${pr.number}\` to check out the branch,`, + `then use \`gh pr diff ${pr.number}\` to review required changes and apply fixes.`, + 'Keep changes scoped to this PR.', + 'Run relevant tests/lint before finishing and summarize what was changed.', + '', + 'Output format:', + '1) What you changed', + '2) Tests run (commands + result)', + '3) Remaining risks / follow-up actions', + `Review body from reviewer: ${String(reviewInfo?.reviewUser || '').trim() ? `by ${String(reviewInfo.reviewUser).trim()}` : 'unknown'}.` + ].filter(Boolean).join('\n'); + } + // --------------------------------------------------------------------------- // Polling lifecycle // --------------------------------------------------------------------------- @@ -190,7 +485,7 @@ class PrReviewAutomationService { return { ok: true, prId, spawned: false }; } - async onReviewSubmitted({ owner, repo, number, reviewState, reviewBody, reviewUser, url }) { + async onReviewSubmitted({ owner, repo, number, reviewState, reviewBody, reviewUser, url, reviewUrl }) { const cfg = this.getConfig(); if (!cfg.enabled || !cfg.webhookEnabled) { return { ignored: true, reason: 'disabled' }; @@ -204,21 +499,60 @@ class PrReviewAutomationService { : state === 'changes_requested' ? 'needs_fix' : 'commented'; + const snapshot = this._buildReviewSnapshot(prId, { + owner, + repo, + number, + url, + reviewBody, + reviewUser, + reviewSubmittedAt: new Date().toISOString(), + reviewUrl: reviewUrl || url + }, outcome, cfg); + this.taskRecordService?.upsert?.(prId, { reviewed: true, reviewedAt: new Date().toISOString(), reviewOutcome: outcome, - reviewEndedAt: new Date().toISOString() + reviewEndedAt: new Date().toISOString(), + ...snapshot }); // Clean up active reviewer tracking this.activeReviewers.delete(prId); - if (outcome === 'needs_fix' && cfg.autoFeedbackToAuthor) { - await this._sendFeedbackToAuthor(prId, { owner, repo, number, reviewBody, reviewUser, outcome }, cfg); - } + const reviewInfo = { + owner, + repo, + number, + url, + reviewBody, + reviewUser, + outcome, + reviewUrl: reviewUrl || url, + reviewSubmittedAt: snapshot.latestReviewSubmittedAt, + reviewSummary: snapshot.latestReviewSummary, + reviewAgent: snapshot.latestReviewAgent + }; - this._emitUpdate('review-completed', { prId, outcome, reviewUser }); + const followUp = await this._handleReviewFollowUp(prId, reviewInfo, cfg, outcome); + this._emitUpdate('review-completed', { + prId, + outcome, + reviewUser, + recordPatch: { + reviewed: true, + reviewedAt: new Date().toISOString(), + reviewOutcome: outcome, + reviewEndedAt: new Date().toISOString(), + ...snapshot, + ...(followUp?.recordPatch || {}) + }, + reviewSummary: snapshot.latestReviewSummary, + reviewUrl: snapshot.latestReviewUrl, + pastedToSessionId: followUp?.sessionId || null, + deliveryAction: followUp?.deliveryAction || null + }); return { ok: true, prId, outcome }; } @@ -307,7 +641,7 @@ class PrReviewAutomationService { try { const prData = await this.pullRequestService.getPullRequest({ owner, repo, number, - fields: ['reviews'] + fields: ['reviews', 'html_url', 'url', 'title'] }); const reviews = prData?.reviews || []; const latestReview = reviews @@ -323,6 +657,10 @@ class PrReviewAutomationService { owner, repo, number, + title: prData?.title || '', + url: prData?.html_url || prData?.url || '', + reviewUrl: latestReview.html_url || prData?.html_url || prData?.url || '', + reviewSubmittedAt: submittedAt, reviewState: latestReview.state, reviewBody: latestReview.body || '', reviewUser: latestReview.author?.login || '' @@ -365,21 +703,69 @@ class PrReviewAutomationService { : outcome === 'changes_requested' ? 'needs_fix' : 'commented'; + const snapshot = this._buildReviewSnapshot(item.prId, { + ...item, + reviewSubmittedAt: item.reviewSubmittedAt || new Date().toISOString(), + reviewUrl: item.reviewUrl || item.url || '' + }, mappedOutcome, cfg); + this.taskRecordService?.upsert?.(item.prId, { reviewed: true, reviewedAt: new Date().toISOString(), reviewOutcome: mappedOutcome, - reviewEndedAt: new Date().toISOString() + reviewEndedAt: new Date().toISOString(), + ...snapshot }); this.activeReviewers.delete(item.prId); logger.info('Review completed', { prId: item.prId, outcome: mappedOutcome }); - if (mappedOutcome === 'needs_fix' && cfg.autoFeedbackToAuthor) { - await this._sendFeedbackToAuthor(item.prId, item, cfg); + const followUp = await this._handleReviewFollowUp(item.prId, { + ...item, + reviewSubmittedAt: snapshot.latestReviewSubmittedAt, + reviewUrl: snapshot.latestReviewUrl, + reviewSummary: snapshot.latestReviewSummary, + reviewAgent: snapshot.latestReviewAgent + }, cfg, mappedOutcome); + + this._emitUpdate('review-completed', { + prId: item.prId, + outcome: mappedOutcome, + reviewUser: item.reviewUser, + recordPatch: { + reviewed: true, + reviewedAt: new Date().toISOString(), + reviewOutcome: mappedOutcome, + reviewEndedAt: new Date().toISOString(), + ...snapshot, + ...(followUp?.recordPatch || {}) + }, + reviewSummary: snapshot.latestReviewSummary, + reviewUrl: snapshot.latestReviewUrl, + pastedToSessionId: followUp?.sessionId || null, + deliveryAction: followUp?.deliveryAction || null + }); + } + + async _handleReviewFollowUp(prId, reviewInfo, cfg, outcome) { + if (outcome === 'needs_fix') { + return this._routeNeedsFixReview(prId, reviewInfo, cfg); + } + + const deliveryAction = this._resolveOutcomeDeliveryAction(outcome, cfg); + if (deliveryAction === 'none') { + return { deliveryAction }; } - this._emitUpdate('review-completed', { prId: item.prId, outcome: mappedOutcome }); + return this._sendFeedbackToAuthor(prId, { + ...reviewInfo, + reviewSummary: reviewInfo?.reviewSummary || summarizeReviewBody(reviewInfo?.reviewBody || ''), + reviewAgent: reviewInfo?.reviewAgent || this._inferReviewerAgent(prId, reviewInfo, cfg) + }, cfg, { + outcome, + deliveryAction, + allowNotesFallback: false + }); } // --------------------------------------------------------------------------- @@ -440,22 +826,32 @@ class PrReviewAutomationService { } // Find an available worktree in the active workspace - const worktreeId = await this._findAvailableWorktree(pr, cfg); + const reviewerConfig = await this._resolveRunnableReviewerConfig(cfg); + if (!reviewerConfig) { + logger.warn('Cannot spawn reviewer - no supported agent CLI available', { prId: pr.prId }); + return false; + } + + const assignment = await this._findAvailableWorktree(pr, reviewerConfig); + + const worktreeId = assignment?.worktreeId; if (!worktreeId) { logger.warn('No available worktree for reviewer', { prId: pr.prId }); return false; } - const sessionId = `${pr.repo || 'review'}-${worktreeId}-claude`; + const sessionId = assignment?.sessionId || `${pr.repo || 'review'}-${worktreeId}-${reviewerConfig.agentId}`; const prompt = this._buildReviewPrompt(pr, cfg); try { - // Start the agent - const agent = cfg.reviewerAgent || 'claude'; const started = this.sessionManager.startAgentWithConfig(sessionId, { - provider: agent, - skipPermissions: true, - mode: 'fresh' + agentId: reviewerConfig.agentId, + provider: reviewerConfig.provider, + mode: reviewerConfig.mode, + flags: reviewerConfig.flags, + model: reviewerConfig.model, + reasoning: reviewerConfig.reasoning, + verbosity: reviewerConfig.verbosity }); if (!started) { @@ -464,7 +860,7 @@ class PrReviewAutomationService { } // Wait for agent init, then send prompt - const initDelay = agent === 'codex' ? 15_000 : 8_000; + const initDelay = reviewerConfig.agentId === 'codex' ? 15_000 : 8_000; setTimeout(() => { this.sessionManager.writeToSession(sessionId, prompt + '\n'); }, initDelay); @@ -480,11 +876,32 @@ class PrReviewAutomationService { this.taskRecordService?.upsert?.(pr.prId, { reviewerSpawnedAt: new Date().toISOString(), reviewerWorktreeId: worktreeId, + reviewerSessionId: sessionId, + reviewerAgent: reviewerConfig.agentId, reviewStartedAt: new Date().toISOString() }); - logger.info('Reviewer agent spawned', { prId: pr.prId, sessionId, worktreeId, agent }); - this._emitUpdate('reviewer-spawned', { prId: pr.prId, sessionId, worktreeId }); + logger.info('Reviewer agent spawned', { + prId: pr.prId, + sessionId, + worktreeId, + agent: reviewerConfig.agentId, + mode: reviewerConfig.mode, + provider: reviewerConfig.provider + }); + this._emitUpdate('reviewer-spawned', { + prId: pr.prId, + sessionId, + worktreeId, + agentId: reviewerConfig.agentId, + recordPatch: { + reviewerSpawnedAt: new Date().toISOString(), + reviewerWorktreeId: worktreeId, + reviewerSessionId: sessionId, + reviewerAgent: reviewerConfig.agentId, + reviewStartedAt: new Date().toISOString() + } + }); return true; } catch (e) { logger.error('Error spawning reviewer', { prId: pr.prId, error: e.message, stack: e.stack }); @@ -492,6 +909,154 @@ class PrReviewAutomationService { } } + async _spawnFixerForPr(pr, cfg, reviewInfo = {}) { + if (!this.sessionManager || !this.workspaceManager) { + logger.warn('Cannot spawn fixer - sessionManager or workspaceManager not available'); + return false; + } + + if (this.activeFixers.has(pr.prId)) { + logger.info('Fixer already active for PR', { prId: pr.prId }); + return false; + } + + const fixerConfig = await this._resolveRunnableReviewerConfig(cfg); + if (!fixerConfig) { + logger.warn('Cannot spawn fixer - no supported agent CLI available', { prId: pr.prId }); + return false; + } + + const assignment = await this._findAvailableWorktree(pr, fixerConfig); + + const worktreeId = assignment?.worktreeId; + if (!worktreeId) { + logger.warn('No available worktree for fixer', { prId: pr.prId }); + return false; + } + + const sessionId = assignment?.sessionId || `${pr.repo || 'fix'}-${worktreeId}-${fixerConfig.agentId}`; + const prompt = this._buildFixPrompt(pr, reviewInfo, cfg); + + try { + const started = this.sessionManager.startAgentWithConfig(sessionId, { + agentId: fixerConfig.agentId, + provider: fixerConfig.provider, + mode: fixerConfig.mode, + flags: fixerConfig.flags, + model: fixerConfig.model, + reasoning: fixerConfig.reasoning, + verbosity: fixerConfig.verbosity + }); + + if (!started) { + logger.warn('Failed to start fixer agent', { sessionId, prId: pr.prId }); + return false; + } + + const initDelay = fixerConfig.agentId === 'codex' ? 15_000 : 8_000; + setTimeout(() => { + this.sessionManager.writeToSession(sessionId, prompt + '\n'); + }, initDelay); + + this.activeFixers.set(pr.prId, { + worktreeId, + sessionId, + spawnedAt: new Date().toISOString() + }); + + this.taskRecordService?.upsert?.(pr.prId, { + fixerSpawnedAt: new Date().toISOString(), + fixerWorktreeId: worktreeId + }); + + logger.info('Fixer agent spawned', { + prId: pr.prId, + sessionId, + worktreeId, + agent: fixerConfig.agentId, + mode: fixerConfig.mode + }); + this._emitUpdate('fixer-spawned', { prId: pr.prId, sessionId, worktreeId }); + return { worktreeId, sessionId }; + } catch (e) { + logger.error('Error spawning fixer', { prId: pr.prId, error: e.message, stack: e.stack }); + return false; + } + } + + async _routeNeedsFixReview(prId, reviewInfo, cfg) { + const action = this._resolvePostReviewAction(prId, cfg); + const parsed = this._getPrIdentityFromId(prId); + const deliveryAction = this._resolveOutcomeDeliveryAction('needs_fix', cfg); + + if (action === 'auto_fix') { + const record = this.taskRecordService?.get?.(prId) || {}; + const resolvedOwner = String(reviewInfo?.owner || '').trim() + || String(record.owner || '').trim() + || parsed.owner; + const resolvedRepo = String(reviewInfo?.repo || '').trim() + || String(record.repo || '').trim() + || parsed.repo; + const resolvedNumber = Number(reviewInfo?.number || parsed.number || 0); + + const resolved = { + ...reviewInfo, + title: String(reviewInfo?.title || record.title || '').trim(), + owner: resolvedOwner, + repo: resolvedRepo, + number: resolvedNumber, + url: String(reviewInfo?.url || record.url || '').trim(), + reviewBody: reviewInfo?.reviewBody || record.notes || '' + }; + const spawnOk = await this._spawnFixerForPr( + { + prId, + number: Number(resolved.number), + title: String(resolved.title || '').trim(), + owner: String(resolved.owner || '').trim(), + repo: String(resolved.repo || '').trim(), + author: String(resolved.reviewUser || '').trim(), + url: String(resolved.url || '').trim() + }, + cfg, + { ...reviewInfo, reviewBody: resolved.reviewBody } + ); + + if (!spawnOk) { + return this._sendFeedbackToAuthor(prId, { + ...resolved, + reviewSummary: summarizeReviewBody(resolved.reviewBody || ''), + reviewAgent: reviewInfo?.reviewAgent || this._inferReviewerAgent(prId, reviewInfo, cfg) + }, cfg, { + outcome: 'needs_fix', + deliveryAction, + allowNotesFallback: true + }); + } + + return { + deliveryAction: 'auto_fix', + recordPatch: { + fixerSpawnedAt: new Date().toISOString(), + fixerWorktreeId: spawnOk?.worktreeId || null + }, + sessionId: spawnOk?.sessionId || null + }; + } + + return this._sendFeedbackToAuthor(prId, { + ...reviewInfo, + owner: reviewInfo?.owner || '', + repo: reviewInfo?.repo || '', + reviewSummary: reviewInfo?.reviewSummary || summarizeReviewBody(reviewInfo?.reviewBody || ''), + reviewAgent: reviewInfo?.reviewAgent || this._inferReviewerAgent(prId, reviewInfo, cfg) + }, cfg, { + outcome: 'needs_fix', + deliveryAction, + allowNotesFallback: true + }); + } + // --------------------------------------------------------------------------- // Internal: find an available worktree for reviewer // --------------------------------------------------------------------------- @@ -509,6 +1074,17 @@ class PrReviewAutomationService { const workspace = this.workspaceManager.getWorkspaceById?.(wsId); if (!workspace) return null; + const reviewerConfig = cfg?.agentId ? cfg : this._resolveReviewerConfig(cfg); + const fallbackRepo = String(pr?.repo || '').trim() || 'review'; + + const preferredSessionIdsForWorktree = (repoPrefix, worktreeId) => { + const ids = [ + `${repoPrefix}-${worktreeId}-${reviewerConfig.agentId}`, + `${repoPrefix}-${worktreeId}-claude`, + `${repoPrefix}-${worktreeId}-codex` + ]; + return [...new Set(ids)]; + }; // Find worktrees that aren't currently used by active reviewers const usedWorktrees = new Set( @@ -523,10 +1099,15 @@ class PrReviewAutomationService { // Check if the session in this worktree is idle/exited const repoName = terminal.repository?.name || terminal.repositoryName || ''; - const claudeSessionId = `${repoName}-${wId}-claude`; - const session = this.sessionManager?.getSessionById?.(claudeSessionId); - if (!session || session.status === 'exited' || session.status === 'idle') { - return wId; + const repoPrefix = String(repoName || fallbackRepo).trim(); + for (const candidateSessionId of preferredSessionIdsForWorktree(repoPrefix, wId)) { + const session = this.sessionManager?.getSessionById?.(candidateSessionId); + if (session && (session.status === 'exited' || session.status === 'idle')) { + return { + worktreeId: wId, + sessionId: candidateSessionId + }; + } } } @@ -568,57 +1149,100 @@ class PrReviewAutomationService { // Internal: send feedback to original author session // --------------------------------------------------------------------------- - async _sendFeedbackToAuthor(prId, reviewInfo, cfg) { + async _sendFeedbackToAuthor(prId, reviewInfo, cfg, options = {}) { const record = this.taskRecordService?.get?.(prId); if (!record) return; - // Try to find the original author's session from the task record - // Session IDs follow pattern: repoName-worktreeId-claude + const outcome = String(options?.outcome || reviewInfo?.outcome || 'needs_fix').trim().toLowerCase(); + const deliveryAction = normalizeDeliveryAction(options?.deliveryAction, outcome === 'needs_fix' ? 'paste_and_notify' : 'notify'); + const allowNotesFallback = options?.allowNotesFallback !== false; + const match = prId.match(/^pr:([^/]+)\/([^#]+)#(\d+)$/); if (!match) return; const [, , repo] = match; - // Look through all sessions for one working on this repo - const sessions = this.sessionManager?.getAllSessions?.() || []; + const preferredAgentId = normalizeAgentId(reviewInfo?.reviewAgent || record?.reviewerAgent || cfg?.reviewerAgent || 'claude'); + const preferredSuffix = `-${preferredAgentId}`; + const repoNeedle = String(repo || '').trim().toLowerCase(); + const targetWorktree = String(record.reviewSourceWorktreeId || '').trim().toLowerCase(); + const configuredSessionId = String(record.reviewSourceSessionId || '').trim(); + + const entries = this.sessionManager?.getAllSessionEntries?.() || []; let targetSession = null; - for (const [sid, session] of sessions) { - if (sid.includes(repo) && sid.endsWith('-claude') && session.status !== 'exited') { - // Check if this session's worktree matches the PR branch - targetSession = sid; + if (configuredSessionId) { + for (const [sid, session] of entries) { + if (String(sid || '').trim() !== configuredSessionId) continue; + if (session?.status === 'exited') continue; + targetSession = configuredSessionId; break; } } - const feedbackMsg = [ - `\n--- PR Review Feedback ---`, - `PR #${reviewInfo.number} has been reviewed by ${reviewInfo.reviewUser || 'AI reviewer'}.`, - `Outcome: CHANGES REQUESTED`, - '', - reviewInfo.reviewBody || '(No detailed comments)', - '', - 'Please address the feedback and push updated commits.', - `--- End Review Feedback ---\n` - ].join('\n'); + const isAgentSession = (sid, session) => { + const lower = String(sid || '').toLowerCase(); + if (session?.status === 'exited') return false; + return lower.endsWith('-claude') || lower.endsWith('-codex'); + }; + + if (!targetSession) { + for (const [sid, session] of entries) { + const sidLower = String(sid || '').toLowerCase(); + if (!isAgentSession(sid, session)) continue; + if (targetWorktree && sidLower.includes(`-${targetWorktree}-`) && sidLower.endsWith(preferredSuffix)) { + targetSession = sid; + break; + } + } + } + + if (!targetSession) { + for (const [sid, session] of entries) { + const sidLower = String(sid || '').toLowerCase(); + if (!isAgentSession(sid, session)) continue; + if (!repoNeedle || sidLower.includes(repoNeedle)) { + if (sidLower.endsWith(preferredSuffix)) { + targetSession = sid; + break; + } + } + } + } + + if (!targetSession) { + for (const [sid, session] of entries) { + const sidLower = String(sid || '').toLowerCase(); + if (!isAgentSession(sid, session)) continue; + if (!repoNeedle || sidLower.includes(repoNeedle)) { + targetSession = sid; + break; + } + } + } - if (targetSession) { + const feedbackMsg = this._buildReviewFeedbackMessage(prId, reviewInfo, outcome); + const notesSummary = String(reviewInfo?.reviewSummary || summarizeReviewBody(reviewInfo?.reviewBody || '') || '(No detailed comments)').trim(); + + if (targetSession && (deliveryAction === 'paste' || deliveryAction === 'paste_and_notify')) { logger.info('Sending review feedback to session', { prId, sessionId: targetSession }); this.sessionManager.writeToSession(targetSession, feedbackMsg); - return; + const deliveredAt = new Date().toISOString(); + this.taskRecordService?.upsert?.(prId, { latestReviewDeliveredAt: deliveredAt }); + return { + deliveryAction, + sessionId: targetSession, + recordPatch: { latestReviewDeliveredAt: deliveredAt } + }; } - // If no active session found and autoSpawnFixer is on, spawn a fixer - if (cfg.autoSpawnFixer) { - logger.info('Original session not found, would spawn fixer', { prId }); + if (allowNotesFallback) { + logger.info('No active session found for review delivery, storing in task record', { prId, deliveryAction }); this.taskRecordService?.upsert?.(prId, { - notes: `Review feedback pending - original session not found. Fixer needed.` - }); - } else { - logger.info('No active session found for feedback, storing in task record', { prId }); - this.taskRecordService?.upsert?.(prId, { - notes: `Review: changes requested by ${reviewInfo.reviewUser || 'AI'}. Feedback: ${(reviewInfo.reviewBody || '').slice(0, 500)}` + notes: `Review (${outcome}) by ${reviewInfo.reviewUser || 'AI'}: ${notesSummary}` }); } + + return { deliveryAction, sessionId: null }; } // --------------------------------------------------------------------------- diff --git a/server/processProjectDashboardService.js b/server/processProjectDashboardService.js index 38a08708..7e045de5 100644 --- a/server/processProjectDashboardService.js +++ b/server/processProjectDashboardService.js @@ -28,6 +28,24 @@ const riskRank = (risk) => { return 0; }; +const extractRepoSlugFromPullRequest = (pr) => { + const nameWithOwner = String(pr?.repository?.nameWithOwner || '').trim(); + if (nameWithOwner) return nameWithOwner; + + const owner = pr?.repository?.owner?.login || pr?.repository?.owner?.name || null; + const name = pr?.repository?.name || null; + if (owner && name) return `${owner}/${name}`; + + const repoSlug = String(pr?.repository || '').trim(); + if (/^[^/]+\/[^/]+$/.test(repoSlug)) return repoSlug; + + const url = String(pr?.url || '').trim(); + const match = url.match(/github\.com\/([^/]+)\/([^/]+)\/pull\/\d+/i); + if (match) return `${match[1]}/${match[2]}`; + + return null; +}; + class ProcessProjectDashboardService { constructor({ pullRequestService, taskRecordService } = {}) { this.pullRequestService = pullRequestService; @@ -64,9 +82,7 @@ class ProcessProjectDashboardService { const recordsById = new Map(); if (this.taskRecordService?.get) { for (const pr of prs) { - const owner = pr?.repository?.owner?.login || pr?.repository?.owner?.name || null; - const name = pr?.repository?.name || null; - const repoSlug = owner && name ? `${owner}/${name}` : null; + const repoSlug = extractRepoSlugFromPullRequest(pr); const id = repoSlug && pr?.number ? `pr:${repoSlug}#${pr.number}` : null; if (!id) continue; // eslint-disable-next-line no-await-in-loop @@ -78,9 +94,7 @@ class ProcessProjectDashboardService { const byRepo = new Map(); for (const pr of prs) { - const owner = pr?.repository?.owner?.login || pr?.repository?.owner?.name || null; - const name = pr?.repository?.name || null; - const repoSlug = owner && name ? `${owner}/${name}` : null; + const repoSlug = extractRepoSlugFromPullRequest(pr); if (!repoSlug) continue; const prId = pr?.number ? `pr:${repoSlug}#${pr.number}` : null; @@ -225,4 +239,3 @@ class ProcessProjectDashboardService { } module.exports = { ProcessProjectDashboardService }; - diff --git a/server/processTaskService.js b/server/processTaskService.js index 88c3f9e7..82f16e45 100644 --- a/server/processTaskService.js +++ b/server/processTaskService.js @@ -12,6 +12,24 @@ const logger = winston.createLogger({ ] }); +const extractRepoSlugFromPullRequest = (pr) => { + const nameWithOwner = String(pr?.repository?.nameWithOwner || '').trim(); + if (nameWithOwner) return nameWithOwner; + + const owner = pr?.repository?.owner?.login || pr?.repository?.owner?.name || null; + const name = pr?.repository?.name || null; + if (owner && name) return `${owner}/${name}`; + + const repoSlug = String(pr?.repository || '').trim(); + if (/^[^/]+\/[^/]+$/.test(repoSlug)) return repoSlug; + + const url = String(pr?.url || '').trim(); + const match = url.match(/github\.com\/([^/]+)\/([^/]+)\/pull\/\d+/i); + if (match) return `${match[1]}/${match[2]}`; + + return null; +}; + class ProcessTaskService { constructor({ sessionManager, worktreeTagService, pullRequestService } = {}) { this.sessionManager = sessionManager; @@ -84,9 +102,7 @@ class ProcessTaskService { }); return (result.prs || []).map(pr => { - const owner = pr?.repository?.owner?.login || pr?.repository?.owner?.name || null; - const name = pr?.repository?.name || null; - const repoSlug = owner && name ? `${owner}/${name}` : null; + const repoSlug = extractRepoSlugFromPullRequest(pr); return { id: repoSlug && pr?.number ? `pr:${repoSlug}#${pr.number}` : `pr:${pr?.url || pr?.number || Math.random()}`, @@ -128,4 +144,3 @@ class ProcessTaskService { } module.exports = { ProcessTaskService }; - diff --git a/server/sessionManager.js b/server/sessionManager.js index f9efc646..23b66a83 100644 --- a/server/sessionManager.js +++ b/server/sessionManager.js @@ -2756,7 +2756,7 @@ class SessionManager extends EventEmitter { return getShellKind(); } - buildClaudeCommand({ shellKind, mode, resumeId, skipPermissions }) { + buildClaudeCommand({ shellKind, mode, resumeId, skipPermissions, model }) { let cmd = 'claude'; if (mode === 'continue') { @@ -2767,6 +2767,10 @@ class SessionManager extends EventEmitter { : 'claude --resume'; } + if (model) { + cmd += ` --model ${quoteForShell(model, shellKind)}`; + } + if (skipPermissions) { cmd += ' --dangerously-skip-permissions'; } @@ -2853,6 +2857,7 @@ class SessionManager extends EventEmitter { shellKind, mode: finalOptions.mode, resumeId: finalOptions.resumeId, + model: finalOptions.model, skipPermissions: !!finalOptions.skipPermissions }); @@ -2932,6 +2937,7 @@ class SessionManager extends EventEmitter { shellKind, mode: finalConfig.mode, resumeId: finalConfig.resumeId, + model: finalConfig.model, skipPermissions }); const resolvedCommand = this.resolveClaudeCommand(claudeCmd, provider); diff --git a/server/taskRecordService.js b/server/taskRecordService.js index 17095355..af92276b 100644 --- a/server/taskRecordService.js +++ b/server/taskRecordService.js @@ -135,6 +135,17 @@ const normalizeReviewOutcome = (v) => { return allowed.has(s) ? s : null; }; +const normalizeReviewerPostAction = (v) => { + const s = String(v || '').trim().toLowerCase(); + return s === 'auto_fix' ? 'auto_fix' : 'feedback'; +}; + +const normalizeAgentId = (v) => { + const s = String(v || '').trim().toLowerCase(); + if (!s) return null; + return s === 'codex' ? 'codex' : (s === 'claude' ? 'claude' : null); +}; + const normalizeDateTime = (v) => { if (v === null || v === '') return null; const dt = new Date(v); @@ -198,6 +209,44 @@ const normalizeReviewChecklist = (raw) => { return Object.keys(out).length ? out : null; }; +const canonicalizePrTaskRecordId = (id) => { + const raw = String(id || '').trim(); + if (!raw) return ''; + + const legacyMatch = raw.match(/^pr:https?:\/\/github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)\/?$/i); + if (legacyMatch) { + return `pr:${legacyMatch[1]}/${legacyMatch[2]}#${legacyMatch[3]}`; + } + + return raw; +}; + +const toLegacyPrTaskRecordId = (id) => { + const raw = String(id || '').trim(); + if (!raw) return null; + + const canonicalMatch = raw.match(/^pr:([^/]+)\/([^#]+)#(\d+)$/i); + if (!canonicalMatch) return null; + + return `pr:https://github.com/${canonicalMatch[1]}/${canonicalMatch[2]}/pull/${canonicalMatch[3]}`; +}; + +const resolveStoredTaskRecordId = (records, id) => { + const raw = String(id || '').trim(); + if (!raw) return ''; + + const all = records && typeof records === 'object' ? records : {}; + if (all[raw]) return raw; + + const canonical = canonicalizePrTaskRecordId(raw); + if (canonical && canonical !== raw && all[canonical]) return canonical; + + const legacy = toLegacyPrTaskRecordId(raw); + if (legacy && all[legacy]) return legacy; + + return raw; +}; + class TaskRecordService { constructor({ filePath } = {}) { this.filePath = filePath || DEFAULT_PATH; @@ -237,24 +286,37 @@ class TaskRecordService { list() { const records = this.data?.records || {}; - return Object.entries(records).map(([id]) => ({ id, ...(this.get(id) || {}) })); + const normalized = new Map(); + + for (const [storedId] of Object.entries(records)) { + const id = canonicalizePrTaskRecordId(storedId) || storedId; + const record = this.get(id) || this.get(storedId); + if (!record) continue; + + if (!normalized.has(id) || storedId === id) { + normalized.set(id, { id, ...record }); + } + } + + return Array.from(normalized.values()); } get(id) { if (!id) return null; - const local = this.data?.records?.[id] || null; + const storageId = resolveStoredTaskRecordId(this.data?.records, id); + const local = this.data?.records?.[storageId] || null; const visibility = String(local?.recordVisibility || '').trim().toLowerCase(); if (visibility !== 'shared' && visibility !== 'encrypted') return local; const repoRoot = String(local?.recordRepoRoot || '').trim(); - const relPath = String(local?.recordPath || '').trim() || getDefaultRepoTaskRecordPaths(id)[visibility]; + const relPath = String(local?.recordPath || '').trim() || getDefaultRepoTaskRecordPaths(storageId)[visibility]; if (!repoRoot || !relPath) return local; try { const passphrase = visibility === 'encrypted' ? getTaskRecordPassphrase() : ''; if (visibility === 'encrypted' && !passphrase) return local; - const fromRepo = readTaskRecordFromRepoSync({ id, repoRoot, relPath, visibility, passphrase }); + const fromRepo = readTaskRecordFromRepoSync({ id: storageId, repoRoot, relPath, visibility, passphrase }); if (!fromRepo) return local; return { ...stripRecordPointers(fromRepo), @@ -397,6 +459,14 @@ class TaskRecordService { } } + if (p.reviewerPostAction !== undefined) { + if (p.reviewerPostAction === null || p.reviewerPostAction === '') { + clear.add('reviewerPostAction'); + } else { + next.reviewerPostAction = normalizeReviewerPostAction(p.reviewerPostAction); + } + } + if (p.reviewStartedAt !== undefined) { const dt = normalizeDateTime(p.reviewStartedAt); if (dt) next.reviewStartedAt = dt; @@ -438,6 +508,39 @@ class TaskRecordService { } } + if (p.reviewerSessionId !== undefined) { + if (p.reviewerSessionId === null || p.reviewerSessionId === '') { + clear.add('reviewerSessionId'); + } else { + next.reviewerSessionId = String(p.reviewerSessionId || '').trim().slice(0, 240); + } + } + + if (p.reviewerAgent !== undefined) { + if (p.reviewerAgent === null || p.reviewerAgent === '') { + clear.add('reviewerAgent'); + } else { + const agentId = normalizeAgentId(p.reviewerAgent); + if (agentId !== null) next.reviewerAgent = agentId; + } + } + + if (p.reviewSourceSessionId !== undefined) { + if (p.reviewSourceSessionId === null || p.reviewSourceSessionId === '') { + clear.add('reviewSourceSessionId'); + } else { + next.reviewSourceSessionId = String(p.reviewSourceSessionId || '').trim().slice(0, 240); + } + } + + if (p.reviewSourceWorktreeId !== undefined) { + if (p.reviewSourceWorktreeId === null || p.reviewSourceWorktreeId === '') { + clear.add('reviewSourceWorktreeId'); + } else { + next.reviewSourceWorktreeId = String(p.reviewSourceWorktreeId || '').trim().slice(0, 120); + } + } + if (p.fixerSpawnedAt !== undefined) { const dt = normalizeDateTime(p.fixerSpawnedAt); if (dt) next.fixerSpawnedAt = dt; @@ -480,6 +583,68 @@ class TaskRecordService { } } + if (p.latestReviewBody !== undefined) { + if (p.latestReviewBody === null || p.latestReviewBody === '') { + clear.add('latestReviewBody'); + } else { + next.latestReviewBody = String(p.latestReviewBody || '').trim().slice(0, 20_000); + } + } + + if (p.latestReviewSummary !== undefined) { + if (p.latestReviewSummary === null || p.latestReviewSummary === '') { + clear.add('latestReviewSummary'); + } else { + next.latestReviewSummary = String(p.latestReviewSummary || '').trim().slice(0, 4_000); + } + } + + if (p.latestReviewOutcome !== undefined) { + if (p.latestReviewOutcome === null || p.latestReviewOutcome === '') { + clear.add('latestReviewOutcome'); + } else { + const outcome = normalizeReviewOutcome(p.latestReviewOutcome); + if (outcome !== null) next.latestReviewOutcome = outcome; + } + } + + if (p.latestReviewUser !== undefined) { + if (p.latestReviewUser === null || p.latestReviewUser === '') { + clear.add('latestReviewUser'); + } else { + next.latestReviewUser = String(p.latestReviewUser || '').trim().slice(0, 240); + } + } + + if (p.latestReviewUrl !== undefined) { + if (p.latestReviewUrl === null || p.latestReviewUrl === '') { + clear.add('latestReviewUrl'); + } else { + next.latestReviewUrl = String(p.latestReviewUrl || '').trim().slice(0, 600); + } + } + + if (p.latestReviewSubmittedAt !== undefined) { + const dt = normalizeDateTime(p.latestReviewSubmittedAt); + if (dt) next.latestReviewSubmittedAt = dt; + else clear.add('latestReviewSubmittedAt'); + } + + if (p.latestReviewAgent !== undefined) { + if (p.latestReviewAgent === null || p.latestReviewAgent === '') { + clear.add('latestReviewAgent'); + } else { + const agentId = normalizeAgentId(p.latestReviewAgent); + if (agentId !== null) next.latestReviewAgent = agentId; + } + } + + if (p.latestReviewDeliveredAt !== undefined) { + const dt = normalizeDateTime(p.latestReviewDeliveredAt); + if (dt) next.latestReviewDeliveredAt = dt; + else clear.add('latestReviewDeliveredAt'); + } + // Optional external ticket/task link (v1: Trello) if (p.ticketProvider !== undefined) { if (p.ticketProvider === null || p.ticketProvider === '') { @@ -619,10 +784,11 @@ class TaskRecordService { if (!id) throw new Error('id is required'); if (!this.data.records) this.data.records = {}; + const storageId = resolveStoredTaskRecordId(this.data.records, id); const nowIso = new Date().toISOString(); - const existed = !!this.data.records[id]; - const existingLocal = this.data.records[id] || {}; + const existed = !!this.data.records[storageId]; + const existingLocal = this.data.records[storageId] || {}; const { next, clear } = this.normalizePatch(patch); const pointerVisibility = String(next.recordVisibility || existingLocal.recordVisibility || 'private').trim().toLowerCase(); @@ -632,14 +798,14 @@ class TaskRecordService { if (!toSharedOrEncrypted && (existingLocal.recordVisibility === 'shared' || existingLocal.recordVisibility === 'encrypted')) { const oldVisibility = String(existingLocal.recordVisibility).trim().toLowerCase(); const repoRoot = String(existingLocal.recordRepoRoot || '').trim(); - const relPath = String(existingLocal.recordPath || '').trim() || getDefaultRepoTaskRecordPaths(id)[oldVisibility]; + const relPath = String(existingLocal.recordPath || '').trim() || getDefaultRepoTaskRecordPaths(storageId)[oldVisibility]; if (repoRoot && relPath) { try { const passphrase = oldVisibility === 'encrypted' ? getTaskRecordPassphrase() : ''; if (oldVisibility !== 'encrypted' || passphrase) { - const fromRepo = readTaskRecordFromRepoSync({ id, repoRoot, relPath, visibility: oldVisibility, passphrase }); + const fromRepo = readTaskRecordFromRepoSync({ id: storageId, repoRoot, relPath, visibility: oldVisibility, passphrase }); if (fromRepo) { - this.data.records[id] = { + this.data.records[storageId] = { ...stripRecordPointers(fromRepo), createdAt: fromRepo.createdAt || existingLocal.createdAt || nowIso, updatedAt: fromRepo.updatedAt || nowIso @@ -652,7 +818,7 @@ class TaskRecordService { } } - const baseLocal = this.data.records[id] || {}; + const baseLocal = this.data.records[storageId] || {}; const nextNonPointers = {}; for (const [k, v] of Object.entries(next)) { if (RECORD_POINTER_KEYS.has(k)) continue; @@ -668,14 +834,14 @@ class TaskRecordService { // private store (local JSON) const merged = { ...mergedCandidate }; for (const k of Array.from(RECORD_POINTER_KEYS)) delete merged[k]; - this.data.records[id] = merged; + this.data.records[storageId] = merged; await this.save(); return merged; } const repoRoot = String(next.recordRepoRoot || existingLocal.recordRepoRoot || '').trim(); if (!repoRoot) throw new Error('recordRepoRoot is required for shared/encrypted records'); - const relPath = String(next.recordPath || existingLocal.recordPath || '').trim() || getDefaultRepoTaskRecordPaths(id)[pointerVisibility]; + const relPath = String(next.recordPath || existingLocal.recordPath || '').trim() || getDefaultRepoTaskRecordPaths(storageId)[pointerVisibility]; if (!relPath) throw new Error('recordPath is required for shared/encrypted records'); if (pointerVisibility === 'encrypted') { @@ -689,7 +855,7 @@ class TaskRecordService { const passphrase = pointerVisibility === 'encrypted' ? getTaskRecordPassphrase() : ''; const existingRepo = (() => { try { - return readTaskRecordFromRepoSync({ id, repoRoot, relPath, visibility: pointerVisibility, passphrase }) || null; + return readTaskRecordFromRepoSync({ id: storageId, repoRoot, relPath, visibility: pointerVisibility, passphrase }) || null; } catch { return null; } @@ -700,7 +866,7 @@ class TaskRecordService { for (const k of clearNonPointers) delete mergedRepo[k]; await writeTaskRecordToRepo({ - id, + id: storageId, repoRoot, relPath, visibility: pointerVisibility, @@ -714,7 +880,7 @@ class TaskRecordService { recordRepoRoot: repoRoot, recordPath: relPath }; - this.data.records[id] = cached; + this.data.records[storageId] = cached; await this.save(); return cached; } @@ -722,8 +888,9 @@ class TaskRecordService { async remove(id) { if (!id) throw new Error('id is required'); if (!this.data.records) this.data.records = {}; - const existed = !!this.data.records[id]; - delete this.data.records[id]; + const storageId = resolveStoredTaskRecordId(this.data.records, id); + const existed = !!this.data.records[storageId]; + delete this.data.records[storageId]; await this.save(); return existed; } @@ -740,7 +907,8 @@ class TaskRecordService { const root = String(repoRoot || '').trim(); if (!root) throw new Error('repoRoot is required'); - const defaults = this.defaultRepoTaskRecordPaths(taskId); + const storageId = resolveStoredTaskRecordId(this.data?.records, taskId); + const defaults = this.defaultRepoTaskRecordPaths(storageId); const rp = String(relPath || defaults[store] || '').trim(); if (!rp) throw new Error('relPath is required'); @@ -749,11 +917,11 @@ class TaskRecordService { throw new Error('Encrypted task records require ORCHESTRATOR_TASK_RECORDS_ENCRYPTION_KEY (or ORCHESTRATOR_TASK_RECORDS_PASSPHRASE) to be set'); } - const existing = this.data?.records?.[taskId] || null; + const existing = this.data?.records?.[storageId] || null; if (!existing) return null; const record = stripRecordPointers(existing); - await writeTaskRecordToRepo({ id: taskId, repoRoot: root, relPath: rp, visibility: store, record, passphrase }); + await writeTaskRecordToRepo({ id: storageId, repoRoot: root, relPath: rp, visibility: store, record, passphrase }); const cached = { ...stripRecordPointers(record), @@ -763,7 +931,7 @@ class TaskRecordService { updatedAt: new Date().toISOString() }; if (!cached.createdAt) cached.createdAt = existing?.createdAt || cached.updatedAt; - this.data.records[taskId] = cached; + this.data.records[storageId] = cached; await this.save(); return cached; } diff --git a/server/userSettingsService.js b/server/userSettingsService.js index 1bf9c88f..b40bfc2a 100644 --- a/server/userSettingsService.js +++ b/server/userSettingsService.js @@ -374,6 +374,32 @@ class UserSettingsService { closeIfNoDoneList: false, pollMs: 60_000 } + }, + prReview: { + enabled: false, + pollEnabled: true, + pollMs: 60_000, + webhookEnabled: true, + reviewerAgent: 'claude', + reviewerMode: 'fresh', + reviewerProvider: 'anthropic', + reviewerClaudeModel: '', + reviewerSkipPermissions: true, + reviewerCodexModel: '', + reviewerCodexReasoning: '', + reviewerCodexVerbosity: '', + reviewerCodexFlags: ['yolo'], + reviewerTier: 3, + autoSpawnReviewer: true, + autoFeedbackToAuthor: true, + autoSpawnFixer: false, + notifyOnReviewerSpawn: true, + notifyOnReviewCompleted: true, + approvedDeliveryAction: 'notify', + commentedDeliveryAction: 'notify', + needsFixFeedbackAction: 'paste_and_notify', + maxConcurrentReviewers: 3, + repos: [] } }, kanban: { @@ -812,6 +838,10 @@ class UserSettingsService { ...((defaultsAutomations.trello || {}).onPrMerged || {}), ...(((next.trello || {}).onPrMerged) || {}) } + }, + prReview: { + ...(defaultsAutomations.prReview || {}), + ...(next.prReview || {}) } }; } diff --git a/server/voiceCommandService.js b/server/voiceCommandService.js index 81c870f8..9f9edc01 100644 --- a/server/voiceCommandService.js +++ b/server/voiceCommandService.js @@ -276,6 +276,15 @@ class VoiceCommandService { command: 'queue-spawn-reviewer', extractParams: () => ({}) }, + { + patterns: [ + /^(?:review|start|run)\s+pr$/i, + /^(?:review|start|run)\s+pull\s+request$/i, + /^(?:review|start|run)\s+review\s+pr$/i, + ], + command: 'queue-review-pr', + extractParams: () => ({}) + }, { patterns: [ /^(?:spawn|start|run)\s+fixer$/i, diff --git a/tests/unit/prReviewAutomationService.test.js b/tests/unit/prReviewAutomationService.test.js new file mode 100644 index 00000000..f3b4c67a --- /dev/null +++ b/tests/unit/prReviewAutomationService.test.js @@ -0,0 +1,270 @@ +jest.mock('winston', () => { + const logger = { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn() + }; + + return { + createLogger: jest.fn(() => logger), + format: { + combine: jest.fn(() => ({})), + timestamp: jest.fn(() => ({})), + errors: jest.fn(() => ({})), + json: jest.fn(() => ({})), + simple: jest.fn(() => ({})), + colorize: jest.fn(() => ({})), + printf: jest.fn(() => ({})) + }, + transports: { + File: jest.fn(), + Console: jest.fn() + } + }; +}, { virtual: true }); + +const { PrReviewAutomationService } = require('../../server/prReviewAutomationService'); +const { SessionManager } = require('../../server/sessionManager'); + +const makeAutomationService = ({ + diagnosticsTools = [ + { id: 'claude', ok: true }, + { id: 'codex', ok: true } + ] +} = {}) => { + const workspace = { + id: 'ws-review', + terminals: [ + { + id: 'demo-work1-claude', + worktreeId: 'work1', + repository: { name: 'demo' } + } + ] + }; + + const sessionManager = { + startAgentWithConfig: jest.fn().mockReturnValue(true), + writeToSession: jest.fn(), + getSessionById: jest.fn(), + getAllSessionEntries: jest.fn(() => []) + }; + + const taskRecordService = { + get: jest.fn(() => null), + upsert: jest.fn() + }; + + const workspaceManager = { + getActiveWorkspace: jest.fn(() => ({ id: workspace.id })), + getWorkspaceById: jest.fn(() => workspace) + }; + + const service = new PrReviewAutomationService({ + sessionManager, + taskRecordService, + workspaceManager, + collectDiagnostics: jest.fn(async () => ({ tools: diagnosticsTools })) + }); + + return { + service, + sessionManager, + taskRecordService, + workspaceManager + }; +}; + +describe('PrReviewAutomationService reviewer launch config', () => { + test('normalizes automation-only reviewer config for Codex and Claude', () => { + const service = new PrReviewAutomationService(); + + const codex = service._resolveReviewerConfig({ + reviewerAgent: 'codex', + reviewerMode: 'resume', + reviewerCodexModel: 'latest', + reviewerCodexReasoning: 'xhigh', + reviewerCodexVerbosity: 'high', + reviewerCodexFlags: ['yolo'] + }); + expect(codex.agentId).toBe('codex'); + expect(codex.mode).toBe('continue'); + expect(codex.model).toBeUndefined(); + expect(codex.reasoning).toBe('xhigh'); + expect(codex.verbosity).toBe('high'); + + const claude = service._resolveReviewerConfig({ + reviewerAgent: 'claude', + reviewerMode: 'resume', + reviewerClaudeModel: 'opus', + reviewerSkipPermissions: true + }); + expect(claude.agentId).toBe('claude'); + expect(claude.mode).toBe('continue'); + expect(claude.model).toBe('opus'); + expect(claude.flags).toEqual(['skipPermissions']); + }); +}); + +describe('PrReviewAutomationService reviewer spawning', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.runOnlyPendingTimers(); + jest.useRealTimers(); + }); + + test('launches Codex in the existing agent shell when no -codex session exists', async () => { + const { service, sessionManager } = makeAutomationService(); + sessionManager.getSessionById.mockImplementation((sessionId) => { + if (sessionId === 'demo-work1-claude') { + return { status: 'idle' }; + } + return null; + }); + + const ok = await service._spawnReviewerForPr( + { + prId: 'pr:acme/demo#12', + owner: 'acme', + repo: 'demo', + number: 12, + title: 'Review me' + }, + { + reviewerAgent: 'codex', + reviewerMode: 'fresh', + reviewerCodexFlags: ['yolo'] + } + ); + + expect(ok).toBe(true); + expect(sessionManager.startAgentWithConfig).toHaveBeenCalledWith( + 'demo-work1-claude', + expect.objectContaining({ + agentId: 'codex', + mode: 'fresh', + flags: ['yolo'] + }) + ); + + jest.runOnlyPendingTimers(); + expect(sessionManager.writeToSession).toHaveBeenCalledWith( + 'demo-work1-claude', + expect.stringContaining('gh pr diff 12') + ); + }); + + test('falls back to Claude when Codex is not installed', async () => { + const { service, sessionManager } = makeAutomationService({ + diagnosticsTools: [ + { id: 'claude', ok: true }, + { id: 'codex', ok: false } + ] + }); + sessionManager.getSessionById.mockImplementation((sessionId) => { + if (sessionId === 'demo-work1-claude') { + return { status: 'idle' }; + } + return null; + }); + + const ok = await service._spawnReviewerForPr( + { + prId: 'pr:acme/demo#42', + owner: 'acme', + repo: 'demo', + number: 42, + title: 'Fallback me' + }, + { + reviewerAgent: 'codex', + reviewerMode: 'fresh', + reviewerSkipPermissions: true + } + ); + + expect(ok).toBe(true); + expect(sessionManager.startAgentWithConfig).toHaveBeenCalledWith( + 'demo-work1-claude', + expect.objectContaining({ + agentId: 'claude', + mode: 'fresh', + flags: ['skipPermissions'] + }) + ); + }); + + test('stores review snapshot and pastes needs-fix feedback back to the source session', async () => { + const { service, sessionManager, taskRecordService } = makeAutomationService(); + service.userSettingsService = { + getAllSettings: () => ({ + global: { + ui: { + tasks: { + automations: { + prReview: { + enabled: true, + webhookEnabled: true, + needsFixFeedbackAction: 'paste_and_notify' + } + } + } + } + } + }) + }; + taskRecordService.get.mockReturnValue({ + reviewSourceSessionId: 'demo-work2-claude', + reviewSourceWorktreeId: 'work2', + reviewerPostAction: 'feedback' + }); + sessionManager.getAllSessionEntries.mockReturnValue([ + ['demo-work2-claude', { status: 'waiting' }] + ]); + + const result = await service.onReviewSubmitted({ + owner: 'acme', + repo: 'demo', + number: 77, + reviewState: 'changes_requested', + reviewBody: 'Fix the failing edge case and add a regression test.', + reviewUser: 'review-bot', + url: 'https://github.com/acme/demo/pull/77', + reviewUrl: 'https://github.com/acme/demo/pull/77#pullrequestreview-1' + }); + + expect(result).toEqual(expect.objectContaining({ ok: true, outcome: 'needs_fix' })); + expect(taskRecordService.upsert).toHaveBeenCalledWith( + 'pr:acme/demo#77', + expect.objectContaining({ + latestReviewOutcome: 'needs_fix', + latestReviewUser: 'review-bot', + latestReviewSummary: expect.stringContaining('Fix the failing edge case'), + latestReviewUrl: 'https://github.com/acme/demo/pull/77#pullrequestreview-1' + }) + ); + expect(sessionManager.writeToSession).toHaveBeenCalledWith( + 'demo-work2-claude', + expect.stringContaining('CHANGES REQUESTED') + ); + }); +}); + +describe('SessionManager.buildClaudeCommand', () => { + test('includes explicit model aliases in Claude launches', () => { + const sessionManager = new SessionManager({ emit: jest.fn() }, { getAllAgents: () => [] }); + + const command = sessionManager.buildClaudeCommand({ + shellKind: 'bash', + mode: 'fresh', + model: 'opus', + skipPermissions: true + }); + + expect(command).toBe("claude --model 'opus' --dangerously-skip-permissions"); + }); +}); diff --git a/tests/unit/processTaskService.test.js b/tests/unit/processTaskService.test.js index 9a9545d0..5e828f5a 100644 --- a/tests/unit/processTaskService.test.js +++ b/tests/unit/processTaskService.test.js @@ -10,7 +10,7 @@ describe('ProcessTaskService', () => { title: 'PR title', state: 'OPEN', url: 'https://example.com/pr/5', - repository: { name: 'repo', owner: { login: 'me' } }, + repository: { name: 'repo', nameWithOwner: 'me/repo' }, createdAt: '2026-01-01T00:00:00Z', updatedAt: '2026-01-03T00:00:00Z', author: { login: 'me' } @@ -42,8 +42,13 @@ describe('ProcessTaskService', () => { const service = new ProcessTaskService({ sessionManager, worktreeTagService, pullRequestService }); const tasks = await service.listTasks(); + const prTask = tasks.find(t => t.kind === 'pr' && t.prNumber === 5); - expect(tasks.some(t => t.kind === 'pr' && t.prNumber === 5)).toBe(true); + expect(prTask).toMatchObject({ + id: 'pr:me/repo#5', + repository: 'me/repo', + prNumber: 5 + }); expect(tasks.some(t => t.kind === 'worktree' && t.worktreePath === '/tmp/work1')).toBe(true); expect(tasks.some(t => t.kind === 'session' && t.sessionId === 's1')).toBe(true); @@ -51,4 +56,3 @@ describe('ProcessTaskService', () => { expect(tasks[0].kind).toBe('session'); }); }); - diff --git a/tests/unit/taskRecordService.test.js b/tests/unit/taskRecordService.test.js index faa45071..77f23a00 100644 --- a/tests/unit/taskRecordService.test.js +++ b/tests/unit/taskRecordService.test.js @@ -57,10 +57,22 @@ describe('TaskRecordService', () => { promptChars: 123, reviewerSpawnedAt: '2026-01-25T00:00:11Z', reviewerWorktreeId: 'work9', + reviewerSessionId: 'demo-work9-claude', + reviewerAgent: 'CLAUDE', + reviewSourceSessionId: 'demo-work1-claude', + reviewSourceWorktreeId: 'work1', fixerSpawnedAt: '2026-01-25T00:00:12Z', fixerWorktreeId: 'work10', recheckSpawnedAt: '2026-01-25T00:00:13Z', - recheckWorktreeId: 'work11' + recheckWorktreeId: 'work11', + latestReviewSummary: 'Fix the edge case.', + latestReviewBody: 'Fix the edge case. Add a regression test.', + latestReviewOutcome: 'NEEDS_FIX', + latestReviewUser: 'review-bot', + latestReviewUrl: 'https://github.com/acme/demo/pull/1#pullrequestreview-1', + latestReviewSubmittedAt: '2026-01-25T00:00:14Z', + latestReviewAgent: 'CODEX', + latestReviewDeliveredAt: '2026-01-25T00:00:15Z' }); expect(rec.reviewStartedAt).toBe('2026-01-25T00:00:00.000Z'); @@ -69,10 +81,22 @@ describe('TaskRecordService', () => { expect(rec.promptChars).toBe(123); expect(rec.reviewerSpawnedAt).toBe('2026-01-25T00:00:11.000Z'); expect(rec.reviewerWorktreeId).toBe('work9'); + expect(rec.reviewerSessionId).toBe('demo-work9-claude'); + expect(rec.reviewerAgent).toBe('claude'); + expect(rec.reviewSourceSessionId).toBe('demo-work1-claude'); + expect(rec.reviewSourceWorktreeId).toBe('work1'); expect(rec.fixerSpawnedAt).toBe('2026-01-25T00:00:12.000Z'); expect(rec.fixerWorktreeId).toBe('work10'); expect(rec.recheckSpawnedAt).toBe('2026-01-25T00:00:13.000Z'); expect(rec.recheckWorktreeId).toBe('work11'); + expect(rec.latestReviewSummary).toBe('Fix the edge case.'); + expect(rec.latestReviewBody).toBe('Fix the edge case. Add a regression test.'); + expect(rec.latestReviewOutcome).toBe('needs_fix'); + expect(rec.latestReviewUser).toBe('review-bot'); + expect(rec.latestReviewUrl).toBe('https://github.com/acme/demo/pull/1#pullrequestreview-1'); + expect(rec.latestReviewSubmittedAt).toBe('2026-01-25T00:00:14.000Z'); + expect(rec.latestReviewAgent).toBe('codex'); + expect(rec.latestReviewDeliveredAt).toBe('2026-01-25T00:00:15.000Z'); }); test('upsert supports prompt repo location fields', async () => { @@ -127,6 +151,24 @@ describe('TaskRecordService', () => { expect(rec2.reviewedAt).toBeUndefined(); }); + test('legacy PR URL ids resolve through canonical ids', async () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'orchestrator-task-records-')); + const filePath = path.join(tmp, 'task-records.json'); + const svc = new TaskRecordService({ filePath }); + + const legacyId = 'pr:https://github.com/me/repo/pull/42'; + await svc.upsert(legacyId, { tier: 3, claimedBy: 'me' }); + + expect(svc.get('pr:me/repo#42')).toMatchObject({ tier: 3, claimedBy: 'me' }); + expect(svc.list().map((r) => r.id)).toContain('pr:me/repo#42'); + + await svc.upsert('pr:me/repo#42', { reviewOutcome: 'needs_fix' }); + + const raw = JSON.parse(fs.readFileSync(filePath, 'utf8')); + expect(raw.records[legacyId].reviewOutcome).toBe('needs_fix'); + expect(raw.records['pr:me/repo#42']).toBeUndefined(); + }); + test('upsert supports overnight runner fields', async () => { const svc = TaskRecordService.getInstance(); svc.data = { version: 1, records: {} }; diff --git a/tests/unit/userSettingsDefaults.test.js b/tests/unit/userSettingsDefaults.test.js index e2b29f2b..a550b891 100644 --- a/tests/unit/userSettingsDefaults.test.js +++ b/tests/unit/userSettingsDefaults.test.js @@ -114,6 +114,18 @@ describe('UserSettingsService defaults', () => { expect(typeof pager.doneCheck.enabled).toBe('boolean'); }); + test('includes PR review automation defaults', () => { + const defaults = UserSettingsService.prototype.getDefaultSettings.call({}); + const prReview = defaults?.global?.ui?.tasks?.automations?.prReview; + expect(prReview).toBeTruthy(); + expect(prReview.reviewerAgent).toBe('claude'); + expect(prReview.notifyOnReviewerSpawn).toBe(true); + expect(prReview.notifyOnReviewCompleted).toBe(true); + expect(prReview.approvedDeliveryAction).toBe('notify'); + expect(prReview.commentedDeliveryAction).toBe('notify'); + expect(prReview.needsFixFeedbackAction).toBe('paste_and_notify'); + }); + test('mergeSettings deep-merges ui.tasks without dropping defaults', () => { const defaults = UserSettingsService.prototype.getDefaultSettings.call({}); const merged = UserSettingsService.prototype.mergeSettings.call({}, defaults, { diff --git a/user-settings.default.json b/user-settings.default.json index f0f98b00..2e5977af 100644 --- a/user-settings.default.json +++ b/user-settings.default.json @@ -177,9 +177,9 @@ }, "reviewInbox": { "mode": "mine", - "tiers": "t3t4", + "tiers": "all", "kind": "pr", - "unreviewedOnly": true, + "unreviewedOnly": false, "autoConsole": false, "autoAdvance": false, "prioritizeActive": true, @@ -258,6 +258,34 @@ "closeIfNoDoneList": false, "pollMs": 60000 } + }, + "prReview": { + "enabled": false, + "pollEnabled": true, + "pollMs": 60000, + "webhookEnabled": true, + "reviewerAgent": "claude", + "reviewerMode": "fresh", + "reviewerProvider": "anthropic", + "reviewerClaudeModel": "", + "reviewerSkipPermissions": true, + "reviewerCodexModel": "", + "reviewerCodexReasoning": "", + "reviewerCodexVerbosity": "", + "reviewerCodexFlags": [ + "yolo" + ], + "reviewerTier": 3, + "autoSpawnReviewer": true, + "autoFeedbackToAuthor": true, + "autoSpawnFixer": false, + "notifyOnReviewerSpawn": true, + "notifyOnReviewCompleted": true, + "approvedDeliveryAction": "notify", + "commentedDeliveryAction": "notify", + "needsFixFeedbackAction": "paste_and_notify", + "maxConcurrentReviewers": 3, + "repos": [] } }, "kanban": {