diff --git a/.env.example b/.env.example index e2e050b16..256527e3c 100644 --- a/.env.example +++ b/.env.example @@ -46,4 +46,33 @@ SESSION_ENCRYPT=false SESSION_PATH=/ SESSION_DOMAIN=null -AUTH_MODEL=App\User \ No newline at end of file +AUTH_MODEL=App\User + +# --------------------------------------------------------------------------- +# Support AI copilot (Phase 1: AI triage + frontend code PRs) +# --------------------------------------------------------------------------- +SUPPORT_AI_ENABLED=false +# One Cursor key for both the headless CLI (triage) and Cloud Agents API (PRs). +CURSOR_API_KEY= + +# Triage brain (Cursor headless CLI: `agent -p --output-format json`) +SUPPORT_AI_TRIAGE_ENABLED=true +SUPPORT_AI_CLI_BIN=agent +SUPPORT_AI_CLI_MODEL=gpt-5.4-mini-medium +SUPPORT_AI_CLI_TIMEOUT=120 + +# Frontend code changes (Cursor Cloud Agents API -> PR into dev) +SUPPORT_AI_CODE_CHANGE_ENABLED=false +SUPPORT_AI_CURSOR_API_BASE=https://api.cursor.com +SUPPORT_AI_CLOUD_MODEL=composer-2.5 +SUPPORT_AI_REPO_URL=https://github.com/codeeu/codeweek +SUPPORT_AI_DEV_BRANCH=dev +SUPPORT_AI_AUTO_CREATE_PR=true +SUPPORT_AI_MAX_POLL_MINUTES=30 + +# Dev -> Live promotion: pr_only | none (never auto-merges to live) +SUPPORT_AI_LIVE_PROMOTION=pr_only +SUPPORT_AI_LIVE_BRANCH=master +# Token-gated; promotion PR is skipped if absent. owner/repo form. +SUPPORT_GITHUB_REPO=codeeu/codeweek +SUPPORT_GITHUB_TOKEN= \ No newline at end of file diff --git a/app/Console/Commands/Support/AiPollAgentsCommand.php b/app/Console/Commands/Support/AiPollAgentsCommand.php new file mode 100644 index 000000000..59aa74000 --- /dev/null +++ b/app/Console/Commands/Support/AiPollAgentsCommand.php @@ -0,0 +1,171 @@ +live PR.'; + + public function handle( + CursorAgentService $cursorAgent, + GitHubPullRequestService $github, + SupportApprovalEmailService $approvalEmail, + SupportActionLogger $logger, + ): int { + if (!$cursorAgent->enabled()) { + $this->maybeJson(['ok' => true, 'skipped' => 'code_change_disabled']); + + return self::SUCCESS; + } + + $cases = SupportCase::query() + ->where('status', 'action_executed') + ->whereNotNull('cursor_agent_id') + ->where('case_type', 'code_change') + ->limit(25) + ->get(); + + $checked = 0; + $finished = 0; + + foreach ($cases as $case) { + $checked++; + $status = $cursorAgent->getAgent((string) $case->cursor_agent_id); + $inner = is_array($status['result'] ?? null) ? $status['result'] : []; + + if (!($status['ok'] ?? false)) { + if ($this->timedOut($case)) { + $this->closeOut($case, $approvalEmail, $logger, false, ['errors' => ['agent_poll_timeout']]); + $finished++; + } + continue; + } + + $agentStatus = $inner['status'] ?? null; + $prUrl = $inner['pr_url'] ?? $case->cursor_pr_url; + + $case->update([ + 'cursor_agent_status' => $agentStatus, + 'cursor_pr_url' => $prUrl, + ]); + + if (!$cursorAgent->isFinished($agentStatus)) { + if ($this->timedOut($case)) { + $this->closeOut($case, $approvalEmail, $logger, false, ['errors' => ['agent_poll_timeout'], 'result' => $inner]); + $finished++; + } + continue; + } + + $succeeded = $cursorAgent->isSuccessful($agentStatus); + $resultInner = $inner; + + if ($succeeded && $prUrl && (string) config('support_ai.live_promotion', 'pr_only') === 'pr_only') { + $promotion = $github->openDevToLivePr( + title: 'Promote dev → '.config('support_ai.live_branch', 'master').' (support copilot)', + body: "Automated release PR opened by the support copilot.\n\nIncludes the fix from case #{$case->id} once merged into dev.\nA developer must review and merge to deploy.", + ); + if (($promotion['ok'] ?? false)) { + $promoUrl = $promotion['result']['pr_url'] ?? null; + $case->update(['live_promotion_pr_url' => $promoUrl]); + $resultInner['promotion_pr_url'] = $promoUrl; + } + } + + $this->closeOut($case, $approvalEmail, $logger, $succeeded, [ + 'ok' => $succeeded, + 'result' => $resultInner, + 'errors' => $succeeded ? [] : ['agent_failed'], + ]); + $finished++; + } + + $this->maybeJson(['ok' => true, 'checked' => $checked, 'finished' => $finished]); + + return self::SUCCESS; + } + + private function timedOut(SupportCase $case): bool + { + $maxMinutes = (int) config('support_ai.code_change.max_poll_minutes', 30); + + return $case->updated_at !== null && $case->updated_at->diffInMinutes(now()) > $maxMinutes; + } + + /** + * @param array $result + */ + private function closeOut( + SupportCase $case, + SupportApprovalEmailService $approvalEmail, + SupportActionLogger $logger, + bool $succeeded, + array $result, + ): void { + $case->update(['status' => $succeeded ? 'verified' : 'escalated']); + + $approval = SupportApproval::query() + ->where('support_case_id', $case->id) + ->where('requested_action', 'code_change') + ->where('status', 'approved') + ->latest('id') + ->first(); + + $envelope = SupportJson::ok('code_change', ['case_id' => $case->id], (array) ($result['result'] ?? [])); + if (!$succeeded) { + $envelope = SupportJson::fail('code_change', ['case_id' => $case->id], (array) ($result['errors'] ?? ['agent_failed'])); + $envelope['result'] = (array) ($result['result'] ?? []); + } + + $logger->log( + case: $case, + actionName: 'code_change_completed', + actionType: 'write', + input: ['case_id' => $case->id, 'agent_id' => $case->cursor_agent_id], + output: $envelope, + succeeded: $succeeded, + executedBy: 'system', + correlationId: $case->correlation_id, + errorMessage: $succeeded ? null : implode(';', (array) ($result['errors'] ?? [])), + ); + + if ($approval !== null) { + try { + $approvalEmail->sendActionCompletion($case, $approval, 'code_change', $envelope, $succeeded); + } catch (\Throwable $e) { + $logger->log( + case: $case, + actionName: 'support_completion_email', + actionType: 'notify', + input: ['case_id' => $case->id], + output: ['ok' => false, 'error' => $e->getMessage()], + succeeded: false, + executedBy: 'system', + correlationId: $case->correlation_id, + errorMessage: $e->getMessage(), + ); + } + } + } + + /** + * @param array $payload + */ + private function maybeJson(array $payload): void + { + if ($this->option('json')) { + $this->line(json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); + } + } +} diff --git a/app/Console/Commands/Support/AiPromoteDevToLiveCommand.php b/app/Console/Commands/Support/AiPromoteDevToLiveCommand.php new file mode 100644 index 000000000..573517d88 --- /dev/null +++ b/app/Console/Commands/Support/AiPromoteDevToLiveCommand.php @@ -0,0 +1,27 @@ + live release PR for a human to review and merge. Never merges.'; + + public function handle(GitHubPullRequestService $github): int + { + $live = (string) config('support_ai.live_branch', 'master'); + + $payload = $github->openDevToLivePr( + title: 'Promote dev → '.$live.' (support copilot)', + body: "Release PR opened by the support copilot.\n\nReview the accumulated changes on dev and merge to deploy to live.\nNothing is merged automatically.", + ); + + $this->line(json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); + + return ($payload['ok'] ?? false) ? self::SUCCESS : self::FAILURE; + } +} diff --git a/app/Console/Commands/Support/AiSetupCheckCommand.php b/app/Console/Commands/Support/AiSetupCheckCommand.php new file mode 100644 index 000000000..e57d271fd --- /dev/null +++ b/app/Console/Commands/Support/AiSetupCheckCommand.php @@ -0,0 +1,108 @@ +resolveBinary($cliBin); + $checks['cli_bin_config'] = $cliBin; + $checks['cli_bin_resolved'] = $resolved; + $checks['cli_bin_executable'] = $resolved !== null && is_executable($resolved); + if (!$checks['cli_bin_executable']) { + $warnings[] = "Cursor CLI not found/executable at '{$cliBin}'. Set SUPPORT_AI_CLI_BIN to the absolute path (e.g. /home/forge/.local/bin/agent)."; + } + + $checks['triage_model'] = (string) config('support_ai.triage.model'); + $checks['cloud_model'] = (string) config('support_ai.code_change.model'); + + // Cloud API key validity + model availability (cheap GET; no token cost). + if ($apiKey !== '') { + $models = $cursorAgent->listModels(); + $checks['cloud_api_reachable'] = (bool) ($models['ok'] ?? false); + if ($models['ok'] ?? false) { + $ids = (array) ($models['result']['models'] ?? []); + $checks['cloud_model_available'] = in_array($checks['cloud_model'], $ids, true); + if (!$checks['cloud_model_available']) { + $warnings[] = "Cloud model '{$checks['cloud_model']}' not in /v1/models — pick a valid id for SUPPORT_AI_CLOUD_MODEL."; + } + } else { + $warnings[] = 'Could not reach Cursor /v1/models with the key: '.implode(';', (array) ($models['errors'] ?? [])); + } + } + + // DB columns from the Phase 1 migration. + $checks['db_columns'] = [ + 'cursor_agent_id' => Schema::hasColumn('support_cases', 'cursor_agent_id'), + 'cursor_agent_status' => Schema::hasColumn('support_cases', 'cursor_agent_status'), + 'cursor_pr_url' => Schema::hasColumn('support_cases', 'cursor_pr_url'), + 'live_promotion_pr_url' => Schema::hasColumn('support_cases', 'live_promotion_pr_url'), + ]; + if (in_array(false, $checks['db_columns'], true)) { + $warnings[] = 'Missing support_cases columns — run: php artisan migrate.'; + } + + $checks['code_change_in_allowed_actions'] = in_array('code_change', (array) config('support_gmail.allowed_write_actions', []), true); + + // Dev -> Live promotion. + $checks['live_promotion'] = (string) config('support_ai.live_promotion', 'pr_only'); + $checks['live_branch'] = (string) config('support_ai.live_branch', 'master'); + $checks['dev_branch'] = (string) config('support_ai.code_change.dev_branch', 'dev'); + $checks['github_token_present'] = trim((string) config('support_ai.github_token', '')) !== ''; + $checks['github_promotion_ready'] = $github->enabled() && $checks['live_promotion'] === 'pr_only'; + if ($checks['live_promotion'] === 'pr_only' && !$checks['github_token_present']) { + $warnings[] = 'SUPPORT_GITHUB_TOKEN empty — dev→live release PR will be skipped until set.'; + } + + $ok = $checks['cursor_api_key_present'] + && $checks['cli_bin_executable'] + && !in_array(false, $checks['db_columns'], true) + && $checks['code_change_in_allowed_actions']; + + $this->line(json_encode([ + 'ok' => $ok, + 'tool' => 'support:ai:setup-check', + 'checks' => $checks, + 'warnings' => $warnings, + ], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); + + return $ok ? self::SUCCESS : self::FAILURE; + } + + private function resolveBinary(string $bin): ?string + { + if (str_contains($bin, '/')) { + return is_file($bin) ? $bin : null; + } + + $path = trim((string) shell_exec('command -v '.escapeshellarg($bin).' 2>/dev/null')); + + return $path !== '' ? $path : null; + } +} diff --git a/app/Jobs/Support/ExecuteApprovedSupportActionJob.php b/app/Jobs/Support/ExecuteApprovedSupportActionJob.php index 5435d42ca..e002e4fba 100644 --- a/app/Jobs/Support/ExecuteApprovedSupportActionJob.php +++ b/app/Jobs/Support/ExecuteApprovedSupportActionJob.php @@ -4,6 +4,7 @@ use App\Models\Support\SupportApproval; use App\Models\Support\SupportCase; +use App\Services\Support\Cursor\CursorAgentService; use App\Services\Support\SupportActionLogger; use App\Services\Support\SupportApprovalEmailService; use App\Services\Support\UserProfileUpdateService; @@ -27,6 +28,7 @@ public function handle( UserProfileUpdateService $userProfileUpdate, SupportApprovalEmailService $approvalEmail, SupportActionLogger $logger, + CursorAgentService $cursorAgent, ): void { $approval = SupportApproval::findOrFail($this->supportApprovalId); @@ -81,6 +83,18 @@ public function handle( } elseif ($action === 'user_profile_update') { // Re-read names from the case email (approval payload may be from an older parser). $result = $userProfileUpdate->updateFromCase($case, dryRun: false, viaEmailApproval: true); + } elseif ($action === 'code_change') { + $result = $cursorAgent->launchCodeAgent( + prompt: (string) ($payload['cursor_prompt'] ?? ''), + startingRef: isset($payload['starting_ref']) ? (string) $payload['starting_ref'] : null, + ); + + $inner = is_array($result['result'] ?? null) ? $result['result'] : []; + $case->update([ + 'cursor_agent_id' => $inner['agent_id'] ?? null, + 'cursor_agent_status' => $inner['status'] ?? null, + 'cursor_pr_url' => $inner['pr_url'] ?? null, + ]); } else { $result = [ 'ok' => false, @@ -93,7 +107,12 @@ public function handle( } $ok = (bool) ($result['ok'] ?? false); - $case->update(['status' => $ok ? 'verified' : 'escalated']); + if ($action === 'code_change') { + // Agent launched asynchronously; poll command captures the PR + closes out. + $case->update(['status' => $ok ? 'action_executed' : 'escalated']); + } else { + $case->update(['status' => $ok ? 'verified' : 'escalated']); + } $logger->log( case: $case, diff --git a/app/Jobs/Support/ProcessSupportCaseDiagnosticsJob.php b/app/Jobs/Support/ProcessSupportCaseDiagnosticsJob.php index c8c546d14..f6b4a7e12 100644 --- a/app/Jobs/Support/ProcessSupportCaseDiagnosticsJob.php +++ b/app/Jobs/Support/ProcessSupportCaseDiagnosticsJob.php @@ -6,6 +6,7 @@ use App\Models\Support\SupportCaseMessage; use App\Services\Support\Agents\DiagnosticsAgentService; use App\Services\Support\SupportActionLogger; +use App\Services\Support\SupportJson; use App\Services\Support\UserProfileUpdateService; use App\Services\Support\UserRestoreService; use Illuminate\Bus\Queueable; @@ -72,6 +73,20 @@ public function handle( ); } + if ($case->case_type === 'code_change') { + $plan = $this->codeChangePlan($case); + $logger->log( + case: $case, + actionName: 'code_change', + actionType: 'write', + input: ['dry_run' => true], + output: $plan, + succeeded: (bool) ($plan['ok'] ?? false), + executedBy: 'agent', + correlationId: $case->correlation_id, + ); + } + // Persist diagnostics snapshot as a message for UI/debugging (stable storage for later external orchestrator). SupportCaseMessage::create([ 'support_case_id' => $case->id, @@ -84,5 +99,34 @@ public function handle( ProcessSupportCaseResolutionJob::dispatchSync($case->id); } + + /** + * Build the (read-only) plan for a code_change case from the triage output. + * No code is touched here — this is the exact instruction we will hand the + * Cursor cloud agent only after a human approves. + * + * @return array + */ + private function codeChangePlan(SupportCase $case): array + { + $triage = (array) ($case->actions()->where('action_name', 'triage')->latest()->first()?->output_json ?? []); + + $cursorPrompt = is_string($triage['cursor_prompt'] ?? null) ? trim($triage['cursor_prompt']) : ''; + $changeSummary = is_string($triage['change_summary'] ?? null) ? trim($triage['change_summary']) : ''; + $devBranch = (string) config('support_ai.code_change.dev_branch', 'dev'); + + if ($cursorPrompt === '') { + return SupportJson::fail('code_change', ['case_id' => $case->id], 'no_cursor_prompt_from_triage'); + } + + return SupportJson::ok('code_change', ['case_id' => $case->id], [ + 'change_summary' => $changeSummary, + 'change_area' => $triage['change_area'] ?? null, + 'cursor_prompt' => $cursorPrompt, + 'starting_ref' => $devBranch, + 'pr_target_branch' => $devBranch, + 'note' => 'A Cursor cloud agent will implement this on a new branch and open a PR into '.$devBranch.'. Nothing deploys automatically.', + ]); + } } diff --git a/app/Services/Support/Agents/CursorCliTriageProvider.php b/app/Services/Support/Agents/CursorCliTriageProvider.php new file mode 100644 index 000000000..97e958b1b --- /dev/null +++ b/app/Services/Support/Agents/CursorCliTriageProvider.php @@ -0,0 +1,247 @@ + "" + * + * Authenticated via the CURSOR_API_KEY env var. Returns a triage result in the + * same stable schema as the deterministic TriageAgentService, plus the + * `code_change` case type for frontend/code fixes. + */ +class CursorCliTriageProvider implements TriageProvider +{ + /** Case types the model is allowed to choose. */ + private const CASE_TYPES = [ + 'account_restore', + 'profile_update', + 'duplicate_account', + 'missing_events', + 'certificate_issue', + 'role_issue', + 'code_change', + 'unknown', + ]; + + public function available(): bool + { + return (bool) config('support_ai.enabled') + && (bool) config('support_ai.triage.enabled') + && trim((string) config('support_ai.cursor_api_key', '')) !== ''; + } + + public function triage(SupportCase $case): ?array + { + if (!$this->available()) { + return null; + } + + $rawText = (string) ($case->normalized_message ?? $case->raw_message ?? ''); + if (trim($rawText) === '') { + return null; + } + + try { + $result = Process::timeout((int) config('support_ai.triage.timeout_seconds', 120)) + ->path(base_path()) + ->env(['CURSOR_API_KEY' => (string) config('support_ai.cursor_api_key')]) + ->run([ + (string) config('support_ai.triage.cli_bin', 'agent'), + '-p', + // --force trusts the workspace non-interactively (no Workspace Trust prompt). + '--force', + '--output-format', 'json', + '--model', (string) config('support_ai.triage.model', 'gpt-5.5'), + $this->buildPrompt($case, $rawText), + ]); + } catch (\Throwable $e) { + Log::warning('Cursor CLI triage failed to run', ['case_id' => $case->id, 'error' => $e->getMessage()]); + + return null; + } + + if (!$result->successful()) { + Log::warning('Cursor CLI triage non-zero exit', [ + 'case_id' => $case->id, + 'exit' => $result->exitCode(), + 'stderr' => mb_substr($result->errorOutput(), 0, 500), + ]); + + return null; + } + + $parsed = $this->parseModelJson($result->output()); + if ($parsed === null) { + Log::warning('Cursor CLI triage produced unparseable output', ['case_id' => $case->id]); + + return null; + } + + return $this->normalize($parsed); + } + + private function buildPrompt(SupportCase $case, string $rawText): string + { + $types = implode(', ', self::CASE_TYPES); + // Keep the ticket text bounded so the CLI invocation stays small. + $ticket = mb_substr($rawText, 0, 6000); + + return <<", + "confidence": , + "target_email": "", + "secondary_emails": [""], + "risk_level": "low|medium|high", + "recommended_runbook": "", + "needs_human_review": , + "reasoning_summary": "", + "profile_firstname": "", + "profile_lastname": "", + "change_summary": "", + "change_area": "", + "cursor_prompt": "" +} + +Ticket subject: {$case->subject} +Ticket body: +\"\"\" +{$ticket} +\"\"\" +PROMPT; + } + + /** + * The CLI's --output-format json wraps the agent result; the model's JSON + * may be the whole payload, a "result"/"text" field, or embedded in prose. + * + * @return array|null + */ + private function parseModelJson(string $stdout): ?array + { + $stdout = trim($stdout); + if ($stdout === '') { + return null; + } + + $direct = json_decode($stdout, true); + if (is_array($direct)) { + if ($this->looksLikeTriage($direct)) { + return $direct; + } + foreach (['result', 'text', 'output', 'response', 'message'] as $key) { + if (isset($direct[$key]) && is_string($direct[$key])) { + $inner = $this->extractFirstJsonObject($direct[$key]); + if ($inner !== null) { + return $inner; + } + } + } + } + + return $this->extractFirstJsonObject($stdout); + } + + /** + * @return array|null + */ + private function extractFirstJsonObject(string $text): ?array + { + if (preg_match('/\{(?:[^{}]|(?R))*\}/s', $text, $m)) { + $decoded = json_decode($m[0], true); + if (is_array($decoded) && $this->looksLikeTriage($decoded)) { + return $decoded; + } + } + + return null; + } + + /** + * @param array $data + */ + private function looksLikeTriage(array $data): bool + { + return array_key_exists('case_type', $data); + } + + /** + * @param array $data + * @return array + */ + private function normalize(array $data): array + { + $caseType = is_string($data['case_type'] ?? null) ? strtolower(trim($data['case_type'])) : 'unknown'; + if (!in_array($caseType, self::CASE_TYPES, true)) { + $caseType = 'unknown'; + } + + $risk = is_string($data['risk_level'] ?? null) ? strtolower(trim($data['risk_level'])) : 'low'; + if (!in_array($risk, ['low', 'medium', 'high'], true)) { + $risk = 'low'; + } + + $confidence = is_numeric($data['confidence'] ?? null) ? (float) $data['confidence'] : 0.5; + $confidence = max(0.0, min(1.0, $confidence)); + + $secondary = []; + foreach ((array) ($data['secondary_emails'] ?? []) as $email) { + if (is_string($email) && $email !== '') { + $secondary[] = strtolower(trim($email)); + } + } + + $requestedAction = match ($caseType) { + 'profile_update' => 'user_profile_update', + 'account_restore' => 'user_restore', + 'code_change' => 'code_change', + default => null, + }; + + return [ + 'case_type' => $caseType, + 'confidence' => $confidence, + 'target_email' => $this->stringOrNull($data['target_email'] ?? null), + 'secondary_emails' => array_values(array_unique($secondary)), + 'target_user_id' => null, + 'requested_action' => $requestedAction, + 'profile_firstname' => $this->stringOrNull($data['profile_firstname'] ?? null), + 'profile_lastname' => $this->stringOrNull($data['profile_lastname'] ?? null), + 'risk_level' => $risk, + 'recommended_runbook' => $this->stringOrNull($data['recommended_runbook'] ?? null) ?? $caseType, + 'needs_human_review' => (bool) ($data['needs_human_review'] ?? false), + 'reasoning_summary' => $this->stringOrNull($data['reasoning_summary'] ?? null) ?? 'AI triage (Cursor CLI).', + 'change_summary' => $this->stringOrNull($data['change_summary'] ?? null), + 'change_area' => $this->stringOrNull($data['change_area'] ?? null), + 'cursor_prompt' => $this->stringOrNull($data['cursor_prompt'] ?? null), + 'triage_source' => 'cursor_cli', + ]; + } + + private function stringOrNull(mixed $value): ?string + { + if (!is_string($value)) { + return null; + } + $value = trim($value); + if ($value === '' || strtolower($value) === 'null') { + return null; + } + + return $value; + } +} diff --git a/app/Services/Support/Agents/TriageAgentService.php b/app/Services/Support/Agents/TriageAgentService.php index 349957db0..8baed972a 100644 --- a/app/Services/Support/Agents/TriageAgentService.php +++ b/app/Services/Support/Agents/TriageAgentService.php @@ -10,10 +10,48 @@ class TriageAgentService { public function __construct( private readonly SupportProfileRequestParser $profileParser, + private readonly CursorCliTriageProvider $aiProvider, ) { } public function triage(SupportCase $case): array + { + $heuristic = $this->heuristicTriage($case); + + $ai = $this->aiProvider->triage($case); + if ($ai === null) { + return $heuristic; + } + + return $this->mergeAiOverHeuristic($ai, $heuristic); + } + + /** + * AI result wins; fall back to the heuristic for any field the model left empty. + * + * @param array $ai + * @param array $heuristic + * @return array + */ + private function mergeAiOverHeuristic(array $ai, array $heuristic): array + { + $merged = $heuristic; + + foreach ($ai as $key => $value) { + if ($value === null || $value === '' || $value === []) { + continue; + } + $merged[$key] = $value; + } + + // Preserve a known target/profile from the heuristic parser when AI omitted it. + $merged['target_email'] = $ai['target_email'] ?? $heuristic['target_email']; + $merged['triage_source'] = $ai['triage_source'] ?? 'cursor_cli'; + + return $merged; + } + + private function heuristicTriage(SupportCase $case): array { $rawText = (string) ($case->normalized_message ?? $case->raw_message ?? ''); $text = Str::lower($rawText); @@ -75,6 +113,10 @@ public function triage(SupportCase $case): array 'recommended_runbook' => $runbook, 'needs_human_review' => $needsHuman, 'reasoning_summary' => 'V1 heuristic triage (LLM integration pending).', + 'change_summary' => null, + 'change_area' => null, + 'cursor_prompt' => null, + 'triage_source' => 'heuristic', ]; } diff --git a/app/Services/Support/Agents/TriageProvider.php b/app/Services/Support/Agents/TriageProvider.php new file mode 100644 index 000000000..c28e9a10e --- /dev/null +++ b/app/Services/Support/Agents/TriageProvider.php @@ -0,0 +1,17 @@ +|null Triage result in the stable schema, or + * null when the provider is unavailable + * (caller falls back to heuristics). + */ + public function triage(SupportCase $case): ?array; +} diff --git a/app/Services/Support/Cursor/CursorAgentService.php b/app/Services/Support/Cursor/CursorAgentService.php new file mode 100644 index 000000000..3fa1f6e25 --- /dev/null +++ b/app/Services/Support/Cursor/CursorAgentService.php @@ -0,0 +1,230 @@ +apiKey() !== ''; + } + + /** + * Launch a cloud agent to make a code change and open a PR into the dev branch. + * + * @return array SupportJson envelope; result holds agent_id/status/pr_url when available. + */ + public function launchCodeAgent(string $prompt, ?string $startingRef = null, ?bool $autoCreatePR = null): array + { + $input = ['starting_ref' => $startingRef ?? $this->devBranch()]; + + if (!$this->enabled()) { + return SupportJson::fail('cursor_launch_agent', $input, 'cursor_code_change_disabled'); + } + + $prompt = trim($prompt); + if ($prompt === '') { + return SupportJson::fail('cursor_launch_agent', $input, 'empty_prompt'); + } + + $body = [ + 'prompt' => ['text' => $prompt], + 'model' => ['id' => (string) config('support_ai.code_change.model', 'composer-2')], + 'repos' => [[ + 'url' => (string) config('support_ai.code_change.repo_url'), + 'startingRef' => $startingRef ?? $this->devBranch(), + ]], + 'autoCreatePR' => $autoCreatePR ?? (bool) config('support_ai.code_change.auto_create_pr', true), + ]; + + try { + $response = $this->client()->post('/v1/agents', $body); + } catch (\Throwable $e) { + Log::warning('Cursor launchCodeAgent request failed', ['error' => $e->getMessage()]); + + return SupportJson::fail('cursor_launch_agent', $input, 'request_failed: '.$e->getMessage()); + } + + if (!$response->successful()) { + return SupportJson::fail('cursor_launch_agent', $input, 'http_'.$response->status().': '.$response->body()); + } + + $data = (array) $response->json(); + $agentId = $this->extractAgentId($data); + + if ($agentId === null) { + return SupportJson::fail('cursor_launch_agent', $input, 'no_agent_id_in_response'); + } + + return SupportJson::ok('cursor_launch_agent', $input, [ + 'agent_id' => $agentId, + 'status' => $this->extractStatus($data), + 'pr_url' => $this->extractPrUrl($data), + 'raw' => $data, + ]); + } + + /** + * List cloud model IDs available to the API key (used by setup-check). + * + * @return array SupportJson envelope; result.models is a list of ids. + */ + public function listModels(): array + { + if ($this->apiKey() === '') { + return SupportJson::fail('cursor_list_models', [], 'cursor_api_key_missing'); + } + + try { + $response = $this->client()->get('/v1/models'); + } catch (\Throwable $e) { + return SupportJson::fail('cursor_list_models', [], 'request_failed: '.$e->getMessage()); + } + + if (!$response->successful()) { + return SupportJson::fail('cursor_list_models', [], 'http_'.$response->status()); + } + + $ids = []; + foreach ((array) $response->json('items', []) as $item) { + if (is_array($item) && isset($item['id']) && is_string($item['id'])) { + $ids[] = $item['id']; + } + } + + return SupportJson::ok('cursor_list_models', [], ['models' => $ids]); + } + + /** + * Fetch the current status (and PR URL when finished) for an agent. + * + * @return array SupportJson envelope. + */ + public function getAgent(string $agentId): array + { + $input = ['agent_id' => $agentId]; + + if ($this->apiKey() === '') { + return SupportJson::fail('cursor_get_agent', $input, 'cursor_api_key_missing'); + } + + try { + $response = $this->client()->get('/v1/agents/'.rawurlencode($agentId)); + } catch (\Throwable $e) { + return SupportJson::fail('cursor_get_agent', $input, 'request_failed: '.$e->getMessage()); + } + + if (!$response->successful()) { + return SupportJson::fail('cursor_get_agent', $input, 'http_'.$response->status().': '.$response->body()); + } + + $data = (array) $response->json(); + + return SupportJson::ok('cursor_get_agent', $input, [ + 'agent_id' => $this->extractAgentId($data) ?? $agentId, + 'status' => $this->extractStatus($data), + 'pr_url' => $this->extractPrUrl($data), + 'finished' => $this->isFinished($this->extractStatus($data)), + 'raw' => $data, + ]); + } + + public function isFinished(?string $status): bool + { + $status = strtoupper((string) $status); + + return in_array($status, ['FINISHED', 'COMPLETED', 'SUCCEEDED', 'DONE', 'FAILED', 'ERROR', 'CANCELLED', 'EXPIRED'], true); + } + + public function isSuccessful(?string $status): bool + { + return in_array(strtoupper((string) $status), ['FINISHED', 'COMPLETED', 'SUCCEEDED', 'DONE'], true); + } + + public function devBranch(): string + { + return (string) config('support_ai.code_change.dev_branch', 'dev'); + } + + private function client(): PendingRequest + { + $timeout = (int) config('support_ai.code_change.request_timeout_seconds', 30); + + return Http::baseUrl(rtrim((string) config('support_ai.code_change.api_base', 'https://api.cursor.com'), '/')) + ->withBasicAuth($this->apiKey(), '') + ->acceptJson() + ->asJson() + ->timeout(max(5, $timeout)); + } + + private function apiKey(): string + { + return trim((string) config('support_ai.cursor_api_key', '')); + } + + /** + * @param array $data + */ + private function extractAgentId(array $data): ?string + { + foreach ([$data['id'] ?? null, $data['agent']['id'] ?? null, $data['agentId'] ?? null] as $candidate) { + if (is_string($candidate) && $candidate !== '') { + return $candidate; + } + } + + return null; + } + + /** + * @param array $data + */ + private function extractStatus(array $data): ?string + { + foreach ([$data['status'] ?? null, $data['agent']['status'] ?? null, $data['run']['status'] ?? null] as $candidate) { + if (is_string($candidate) && $candidate !== '') { + return $candidate; + } + } + + return null; + } + + /** + * @param array $data + */ + private function extractPrUrl(array $data): ?string + { + $candidates = [ + $data['prUrl'] ?? null, + $data['pullRequest']['url'] ?? null, + $data['target']['prUrl'] ?? null, + $data['target']['pullRequestUrl'] ?? null, + $data['git']['prUrl'] ?? null, + $data['agent']['prUrl'] ?? null, + $data['agent']['target']['prUrl'] ?? null, + ]; + + foreach ($candidates as $candidate) { + if (is_string($candidate) && str_contains($candidate, '://')) { + return $candidate; + } + } + + return null; + } +} diff --git a/app/Services/Support/Cursor/GitHubPullRequestService.php b/app/Services/Support/Cursor/GitHubPullRequestService.php new file mode 100644 index 000000000..4367242f2 --- /dev/null +++ b/app/Services/Support/Cursor/GitHubPullRequestService.php @@ -0,0 +1,110 @@ + live promotion pull request via the GitHub REST API. + * + * This NEVER merges. It only opens (or reuses) a PR from the dev branch into + * the live branch so a human can review and merge, which triggers the Forge + * auto-deploy. Token-gated: degrades to a no-op when no token is configured. + */ +class GitHubPullRequestService +{ + public function enabled(): bool + { + return $this->token() !== '' && $this->repo() !== ''; + } + + /** + * Open (or reuse) a PR from dev into live. + * + * @return array SupportJson envelope. + */ + public function openDevToLivePr(string $title, string $body): array + { + $dev = (string) config('support_ai.code_change.dev_branch', 'dev'); + $live = (string) config('support_ai.live_branch', 'master'); + $input = ['head' => $dev, 'base' => $live, 'repo' => $this->repo()]; + + if ((string) config('support_ai.live_promotion', 'pr_only') !== 'pr_only') { + return SupportJson::fail('github_open_pr', $input, 'live_promotion_disabled'); + } + + if (!$this->enabled()) { + return SupportJson::fail('github_open_pr', $input, 'github_token_or_repo_missing'); + } + + $existing = $this->findOpenPr($dev, $live); + if ($existing !== null) { + return SupportJson::ok('github_open_pr', $input, ['pr_url' => $existing, 'reused' => true]); + } + + try { + $response = $this->client()->post('/repos/'.$this->repo().'/pulls', [ + 'title' => $title, + 'head' => $dev, + 'base' => $live, + 'body' => $body, + ]); + } catch (\Throwable $e) { + return SupportJson::fail('github_open_pr', $input, 'request_failed: '.$e->getMessage()); + } + + if (!$response->successful()) { + return SupportJson::fail('github_open_pr', $input, 'http_'.$response->status().': '.$response->body()); + } + + $url = (string) ($response->json('html_url') ?? ''); + + return SupportJson::ok('github_open_pr', $input, ['pr_url' => $url, 'reused' => false]); + } + + private function findOpenPr(string $head, string $base): ?string + { + $owner = explode('/', $this->repo())[0] ?? ''; + + try { + $response = $this->client()->get('/repos/'.$this->repo().'/pulls', [ + 'state' => 'open', + 'base' => $base, + 'head' => $owner.':'.$head, + ]); + } catch (\Throwable) { + return null; + } + + if (!$response->successful()) { + return null; + } + + $first = $response->json('0'); + + return is_array($first) ? ($first['html_url'] ?? null) : null; + } + + private function client(): PendingRequest + { + return Http::baseUrl('https://api.github.com') + ->withToken($this->token()) + ->withHeaders([ + 'Accept' => 'application/vnd.github+json', + 'X-GitHub-Api-Version' => '2022-11-28', + ]) + ->timeout(30); + } + + private function token(): string + { + return trim((string) config('support_ai.github_token', '')); + } + + private function repo(): string + { + return trim((string) config('support_ai.github_repo', '')); + } +} diff --git a/app/Services/Support/SupportApprovalEmailService.php b/app/Services/Support/SupportApprovalEmailService.php index 738a13d16..e8dc8aedb 100644 --- a/app/Services/Support/SupportApprovalEmailService.php +++ b/app/Services/Support/SupportApprovalEmailService.php @@ -24,6 +24,7 @@ public function approvalSubject(SupportCase $case): string $headline = match ($case->case_type) { 'profile_update' => 'Please review — name change', 'account_restore' => 'Please review — account restore', + 'code_change' => 'Please review — proposed code fix (PR into dev)', default => 'Please review before we make changes', }; @@ -277,9 +278,42 @@ private function proposedActionForCase(SupportCase $case): array } } + if ($case->case_type === 'code_change') { + $plan = $this->codeChangePlan($case); + if (($plan['cursor_prompt'] ?? '') !== '') { + return [ + 'action' => 'code_change', + 'payload' => [ + 'cursor_prompt' => $plan['cursor_prompt'], + 'starting_ref' => $plan['starting_ref'] ?? (string) config('support_ai.code_change.dev_branch', 'dev'), + 'change_summary' => $plan['change_summary'] ?? '', + 'change_area' => $plan['change_area'] ?? null, + ], + ]; + } + } + return ['action' => 'none', 'payload' => []]; } + /** + * Read the code_change dry-run plan recorded during diagnostics. + * + * @return array + */ + private function codeChangePlan(SupportCase $case): array + { + $action = $case->actions() + ->where('action_name', 'code_change') + ->where('action_type', 'write') + ->latest() + ->first()?->output_json; + + $result = is_array($action) ? ($action['result'] ?? []) : []; + + return is_array($result) ? $result : []; + } + /** * @param array{action: string, payload: array} $proposedAction */ @@ -387,6 +421,10 @@ private function dryRunPlannedChangeLines(SupportCase $case, array $proposedActi ]; } + if ($action === 'code_change') { + return $this->dryRunCodeChangeLines($payload); + } + return [ '', 'We could not determine an automatic change from this email.', @@ -394,6 +432,38 @@ private function dryRunPlannedChangeLines(SupportCase $case, array $proposedActi ]; } + /** + * @param array $payload + * @return list + */ + private function dryRunCodeChangeLines(array $payload): array + { + $devBranch = (string) ($payload['starting_ref'] ?? config('support_ai.code_change.dev_branch', 'dev')); + $summary = trim((string) ($payload['change_summary'] ?? '')); + $prompt = trim((string) ($payload['cursor_prompt'] ?? '')); + + $lines = ['', 'We will ask the AI coding agent to make this change:']; + if ($summary !== '') { + $lines[] = ' • '.$summary; + } + if (($payload['change_area'] ?? null)) { + $lines[] = ' • Area: '.$payload['change_area']; + } + + $lines[] = ''; + $lines[] = 'Exactly what the agent will be instructed to do:'; + $lines[] = str_repeat('·', 20); + foreach (preg_split('/\r\n|\r|\n/', $prompt) ?: [] as $promptLine) { + $lines[] = ' '.$promptLine; + } + + $lines[] = ''; + $lines[] = 'The agent works on a new branch and opens a Pull Request into "'.$devBranch.'"'; + $lines[] = 'for a developer to review. NOTHING is merged or deployed automatically.'; + + return $lines; + } + /** * @param array $payload * @return list @@ -464,6 +534,7 @@ private function completionHeadline(SupportCase $case, string $action, bool $suc return match ($action) { 'user_profile_update' => 'Done — name updated on CodeWeek account', 'user_restore' => 'Done — CodeWeek account reactivated', + 'code_change' => 'Started — AI coding agent is preparing a PR into dev', default => 'Done — your approved request was completed', }; } @@ -573,6 +644,34 @@ private function completionSuccessLines(SupportCase $case, string $action, array ]; } + if ($action === 'code_change') { + $prUrl = (string) ($inner['pr_url'] ?? ''); + $agentId = (string) ($inner['agent_id'] ?? ''); + $lines = [ + 'We started an AI coding agent to implement the approved fix.', + 'It will push to a new branch and open a Pull Request into the dev branch.', + ]; + if ($prUrl !== '') { + $lines[] = ''; + $lines[] = 'Pull request: '.$prUrl; + } else { + $lines[] = ''; + $lines[] = 'The pull request link will follow in a moment once the agent finishes.'; + } + if ($agentId !== '') { + $lines[] = 'Agent reference: '.$agentId; + } + $promoUrl = (string) ($inner['promotion_pr_url'] ?? ''); + if ($promoUrl !== '') { + $lines[] = ''; + $lines[] = 'Release PR (dev → live, for when the fix is ready to deploy): '.$promoUrl; + } + $lines[] = ''; + $lines[] = 'A developer must review and merge the PR — nothing deploys automatically.'; + + return $lines; + } + return [ 'The approved request for case #'.$case->id.' was completed successfully.', ]; diff --git a/config/support_ai.php b/config/support_ai.php new file mode 100644 index 000000000..e3c40cf2e --- /dev/null +++ b/config/support_ai.php @@ -0,0 +1,63 @@ + env('SUPPORT_AI_ENABLED', false), + + // Single Cursor key powers both the headless CLI (triage brain) and the + // Cloud Agents API (code changes + PRs). Service-account key recommended. + 'cursor_api_key' => env('CURSOR_API_KEY'), + + /* + |-------------------------------------------------------------------------- + | Triage brain — Cursor headless CLI (agent -p --output-format json) + |-------------------------------------------------------------------------- + */ + 'triage' => [ + 'enabled' => env('SUPPORT_AI_TRIAGE_ENABLED', true), + // Path to the Cursor CLI binary on the server (installed via cursor.com/install). + 'cli_bin' => env('SUPPORT_AI_CLI_BIN', 'agent'), + 'model' => env('SUPPORT_AI_CLI_MODEL', 'gpt-5.4-mini-medium'), + 'timeout_seconds' => (int) env('SUPPORT_AI_CLI_TIMEOUT', 120), + ], + + /* + |-------------------------------------------------------------------------- + | Frontend code changes — Cursor Cloud Agents API + |-------------------------------------------------------------------------- + | A cloud agent makes the change on a cursor/... branch and opens a PR into + | the dev branch. Requires the GitHub repo connected to Cursor. + */ + 'code_change' => [ + 'enabled' => env('SUPPORT_AI_CODE_CHANGE_ENABLED', false), + 'api_base' => env('SUPPORT_AI_CURSOR_API_BASE', 'https://api.cursor.com'), + 'model' => env('SUPPORT_AI_CLOUD_MODEL', 'composer-2.5'), + 'repo_url' => env('SUPPORT_AI_REPO_URL', 'https://github.com/codeeu/codeweek'), + 'dev_branch' => env('SUPPORT_AI_DEV_BRANCH', 'dev'), + 'auto_create_pr' => filter_var(env('SUPPORT_AI_AUTO_CREATE_PR', true), FILTER_VALIDATE_BOOL), + 'request_timeout_seconds' => (int) env('SUPPORT_AI_CLOUD_TIMEOUT', 30), + 'max_poll_minutes' => (int) env('SUPPORT_AI_MAX_POLL_MINUTES', 30), + ], + + /* + |-------------------------------------------------------------------------- + | Dev -> Live promotion + |-------------------------------------------------------------------------- + | 'pr_only' : after a fix lands in dev, open a dev -> live PR for a human + | to merge (Forge auto-deploys live). Never auto-merges. + | 'none' : never touch live; only ever PR into dev. + */ + 'live_promotion' => env('SUPPORT_AI_LIVE_PROMOTION', 'pr_only'), + 'live_branch' => env('SUPPORT_AI_LIVE_BRANCH', 'master'), + + // GitHub REST access for opening the dev -> live promotion PR (token-gated; + // promotion is skipped gracefully if absent). owner/repo form. + 'github_repo' => env('SUPPORT_GITHUB_REPO', 'codeeu/codeweek'), + 'github_token' => env('SUPPORT_GITHUB_TOKEN'), +]; diff --git a/config/support_gmail.php b/config/support_gmail.php index 062bf44f5..dbee90f52 100644 --- a/config/support_gmail.php +++ b/config/support_gmail.php @@ -77,6 +77,7 @@ 'allowed_write_actions' => [ 'user_restore', 'user_profile_update', + 'code_change', ], // Send a follow-up email after an APPROVE action runs (success or failure). diff --git a/database/migrations/2026_06_23_090000_add_cursor_agent_fields_to_support_cases_table.php b/database/migrations/2026_06_23_090000_add_cursor_agent_fields_to_support_cases_table.php new file mode 100644 index 000000000..aef3af3e7 --- /dev/null +++ b/database/migrations/2026_06_23_090000_add_cursor_agent_fields_to_support_cases_table.php @@ -0,0 +1,36 @@ +string('cursor_agent_id')->nullable()->index()->after('assigned_runbook'); + } + if (!Schema::hasColumn('support_cases', 'cursor_agent_status')) { + $table->string('cursor_agent_status')->nullable()->after('cursor_agent_id'); + } + if (!Schema::hasColumn('support_cases', 'cursor_pr_url')) { + $table->string('cursor_pr_url')->nullable()->after('cursor_agent_status'); + } + if (!Schema::hasColumn('support_cases', 'live_promotion_pr_url')) { + $table->string('live_promotion_pr_url')->nullable()->after('cursor_pr_url'); + } + }); + } + + public function down(): void + { + Schema::table('support_cases', function (Blueprint $table) { + foreach (['cursor_agent_id', 'cursor_agent_status', 'cursor_pr_url', 'live_promotion_pr_url'] as $column) { + if (Schema::hasColumn('support_cases', $column)) { + $table->dropColumn($column); + } + } + }); + } +}; diff --git a/docs/support-copilot-ai.md b/docs/support-copilot-ai.md new file mode 100644 index 000000000..56812c745 --- /dev/null +++ b/docs/support-copilot-ai.md @@ -0,0 +1,117 @@ +# CodeWeek Support Copilot — AI capabilities (Phase 1) + +**Status:** Phase 1 · AI triage + frontend code fixes as PRs into `dev` +**Phase 2 (planned):** AI-driven `artisan` changes on the server (allowlist-first, dry-run + APPROVE) + +This builds on the email pipeline in [support-copilot-stakeholder-guide.md](./support-copilot-stakeholder-guide.md) +and the action matrix in [support-copilot-allowed-actions.md](./support-copilot-allowed-actions.md). + +--- + +## What changed + +| Before | Now (Phase 1) | +|--------|----------------| +| Deterministic keyword triage | **AI triage** via the Cursor headless CLI, with the keyword rules as automatic fallback | +| Only user data actions (`user_restore`, `user_profile_update`) | Adds **`code_change`**: a frontend/code fix implemented by a Cursor cloud agent as a **PR into `dev`** | +| — | Optional **dev → live release PR** opened for a human to merge (never auto-merged) | + +The safety model is unchanged: in dry-run mode every write (including `code_change`) +sends a summary and only runs after an emailed **APPROVE** from an allowed domain. + +--- + +## One key, two Cursor surfaces + +A single `CURSOR_API_KEY` (a Cursor **service account** key is recommended) powers both: + +| Surface | Used for | How | +|---------|----------|-----| +| Cursor **headless CLI** (`agent -p --output-format json`) | The triage "brain" | Runs on the Forge server | +| Cursor **Cloud Agents API** (`POST https://api.cursor.com/v1/agents`) | Code change + PR into `dev` | Runs in Cursor's cloud against the connected GitHub repo | + +Prerequisites: + +- Install the CLI on the server: `curl https://cursor.com/install -fsS | bash` +- Connect `github.com/codeeu/codeweek` to Cursor (Cloud Agents need GitHub access) +- Set the env vars below in Forge + +--- + +## Flow + +```mermaid +flowchart TD + A[Ticket email] --> B[AI triage - Cursor CLI] + B --> C{case_type} + C -->|user data| D[user_restore / profile_update] + C -->|code_change| E[Dry-run: exact agent prompt + target branch] + E --> F[Summary email -> APPROVE] + F -->|APPROVE| G[Cloud agent: branch + PR into dev] + G --> H[poll-agents: capture PR link] + H --> I[Report email: PR link] + H -->|pr_only| J[Open/reuse dev -> live release PR] +``` + +1. **Triage** — the CLI returns a JSON classification. If AI is disabled or fails, the keyword rules run instead. +2. **Dry-run** — for `code_change` the summary email shows the **exact instruction** the agent will receive and the target branch (`dev`). Nothing runs yet. +3. **APPROVE** — reply `APPROVE` in-thread (same rules as all other actions). +4. **Execute** — a Cursor cloud agent makes the change on a `cursor/...` branch and opens a **PR into `dev`**. +5. **Report** — `support:ai:poll-agents` (scheduled every minute) captures the PR link and emails it, and — when `SUPPORT_AI_LIVE_PROMOTION=pr_only` — opens/reuses a **dev → live** release PR for a developer to merge. + +**Nothing is ever merged or deployed automatically.** + +--- + +## Configuration (`config/support_ai.php`) + +| Env var | Default | Purpose | +|---------|---------|---------| +| `SUPPORT_AI_ENABLED` | `false` | Master switch for all AI features | +| `CURSOR_API_KEY` | — | Cursor service-account key (CLI + Cloud API) | +| `SUPPORT_AI_TRIAGE_ENABLED` | `true` | Use AI triage (falls back to keywords) | +| `SUPPORT_AI_CLI_BIN` | `agent` | Path to the Cursor CLI binary | +| `SUPPORT_AI_CLI_MODEL` | `gpt-5.4-mini-medium` | Model for triage (any id from `agent models`) | +| `SUPPORT_AI_CODE_CHANGE_ENABLED` | `false` | Enable the `code_change` action | +| `SUPPORT_AI_REPO_URL` | `https://github.com/codeeu/codeweek` | Repo for cloud agents | +| `SUPPORT_AI_DEV_BRANCH` | `dev` | PR target branch | +| `SUPPORT_AI_CLOUD_MODEL` | `composer-2.5` | Model for the cloud coding agent (verify via `GET /v1/models`) | +| `SUPPORT_AI_AUTO_CREATE_PR` | `true` | Agent opens the PR on completion | +| `SUPPORT_AI_MAX_POLL_MINUTES` | `30` | Give up polling an agent after N minutes | +| `SUPPORT_AI_LIVE_PROMOTION` | `pr_only` | `pr_only` opens dev→live PR; `none` disables | +| `SUPPORT_AI_LIVE_BRANCH` | `master` | Live branch (Forge auto-deploys this) | +| `SUPPORT_GITHUB_REPO` | `codeeu/codeweek` | owner/repo for the promotion PR | +| `SUPPORT_GITHUB_TOKEN` | — | Token to open the dev→live PR (skipped if absent) | + +--- + +## Commands + +| Command | Purpose | +|---------|---------| +| `php artisan support:ai:setup-check` | Verify the Cursor key, CLI binary path, model availability, DB columns, and GitHub token | +| `php artisan support:ai:poll-agents` | Check in-flight code-change agents, capture PR links, report, open dev→live PR (scheduled every minute when enabled) | +| `php artisan support:ai:promote-dev-to-live` | Manually open/reuse the dev → live release PR | + +--- + +## Rollout checklist + +0. Run `php artisan support:ai:setup-check` and fix any warnings before enabling. +1. `SUPPORT_AI_ENABLED=true`, set `CURSOR_API_KEY`, keep `SUPPORT_AI_CODE_CHANGE_ENABLED=false` → validate AI triage only. +2. Install Cursor CLI on the server; confirm `CURSOR_API_KEY=... agent -p --force "hello"` works (the `--force` flag skips the Workspace Trust prompt — the bot passes it automatically). +3. Connect the GitHub repo to Cursor, then `SUPPORT_AI_CODE_CHANGE_ENABLED=true` → first `code_change` ticket, verify the dry-run email shows the exact prompt. +4. APPROVE once; confirm a PR opens into `dev` and the report email arrives with the link. +5. Set `SUPPORT_GITHUB_TOKEN` to enable the dev→live release PR. + +Keep `SUPPORT_GMAIL_DRY_RUN=true` throughout so every change still needs an emailed APPROVE. + +--- + +## Phase 2 (not yet built) — AI `artisan` changes over SSH + +Agreed design for the next phase: + +- **Allowlist-first:** an `ArtisanActionRegistry` of permitted commands with validated args. The AI may only pick from these. +- **Fallback:** if no allowlisted command fits, the AI may propose a raw command string — still **dry-run first**, the **exact command** is emailed, and it only runs after **APPROVE**. +- **Report:** after execution, a completion email reports what ran and the result (reusing the existing completion-email pipeline). diff --git a/docs/support-copilot-allowed-actions.md b/docs/support-copilot-allowed-actions.md index 800f8f4f5..30b0329a9 100644 --- a/docs/support-copilot-allowed-actions.md +++ b/docs/support-copilot-allowed-actions.md @@ -36,8 +36,10 @@ These are the **only** actions that can change production data via the email pip |-----------|-----------|--------------|-------------------| | `user_restore` | `account_restore` | Restores a **soft-deleted** user | User email + words like *restore*, *deleted account* | | `user_profile_update` | `profile_update` | Updates `firstname` and/or `lastname` on `users` | User email + requested first/last name | +| `code_change` | `code_change` | AI cloud agent implements a frontend/code fix and opens a **PR into `dev`** (never deploys) | Description of the bug/change in the website code | -Configured in `config/support_gmail.php` → `allowed_write_actions`. +Configured in `config/support_gmail.php` → `allowed_write_actions`. AI features are +configured in `config/support_ai.php` — see [support-copilot-ai.md](./support-copilot-ai.md). --- @@ -145,4 +147,7 @@ php artisan support:gmail:setup-check --- -See also: [support-copilot-stakeholder-guide.md](./support-copilot-stakeholder-guide.md) +See also: + +- [support-copilot-stakeholder-guide.md](./support-copilot-stakeholder-guide.md) +- [support-copilot-ai.md](./support-copilot-ai.md) — AI triage + frontend code PRs (Phase 1) diff --git a/routes/console.php b/routes/console.php index 7be049905..774cb0d49 100644 --- a/routes/console.php +++ b/routes/console.php @@ -50,3 +50,8 @@ } else { $supportGmailPoll->cron('*/'.$supportPollMinutes.' * * * *'); } + +// Support AI copilot: capture finished code-change agents' PR links and report back. +Schedule::command('support:ai:poll-agents') + ->everyMinute() + ->when(fn () => (bool) config('support_ai.enabled') && (bool) config('support_ai.code_change.enabled')); diff --git a/tests/Unit/Support/CursorAgentServiceTest.php b/tests/Unit/Support/CursorAgentServiceTest.php new file mode 100644 index 000000000..dc0fa3222 --- /dev/null +++ b/tests/Unit/Support/CursorAgentServiceTest.php @@ -0,0 +1,74 @@ +set('support_ai.enabled', true); + config()->set('support_ai.code_change.enabled', true); + config()->set('support_ai.cursor_api_key', 'test-key'); + config()->set('support_ai.code_change.api_base', 'https://api.cursor.com'); + config()->set('support_ai.code_change.repo_url', 'https://github.com/codeeu/codeweek'); + config()->set('support_ai.code_change.dev_branch', 'dev'); + config()->set('support_ai.code_change.model', 'composer-2'); + } + + public function test_launch_code_agent_posts_and_parses_agent_id(): void + { + Http::fake([ + 'api.cursor.com/v1/agents' => Http::response([ + 'id' => 'bc_abc123', + 'status' => 'RUNNING', + ], 200), + ]); + + $result = app(CursorAgentService::class)->launchCodeAgent('Fix the footer link', 'dev'); + + $this->assertTrue($result['ok']); + $this->assertSame('bc_abc123', $result['result']['agent_id']); + + Http::assertSent(function ($request) { + $body = $request->data(); + + return $request->url() === 'https://api.cursor.com/v1/agents' + && $body['prompt']['text'] === 'Fix the footer link' + && $body['repos'][0]['startingRef'] === 'dev' + && $body['autoCreatePR'] === true; + }); + } + + public function test_launch_is_disabled_when_flag_off(): void + { + config()->set('support_ai.code_change.enabled', false); + + $result = app(CursorAgentService::class)->launchCodeAgent('whatever', 'dev'); + + $this->assertFalse($result['ok']); + $this->assertContains('cursor_code_change_disabled', $result['errors']); + } + + public function test_get_agent_extracts_pr_url_and_finished_state(): void + { + Http::fake([ + 'api.cursor.com/v1/agents/bc_abc123' => Http::response([ + 'id' => 'bc_abc123', + 'status' => 'FINISHED', + 'target' => ['prUrl' => 'https://github.com/codeeu/codeweek/pull/42'], + ], 200), + ]); + + $result = app(CursorAgentService::class)->getAgent('bc_abc123'); + + $this->assertTrue($result['ok']); + $this->assertTrue($result['result']['finished']); + $this->assertSame('https://github.com/codeeu/codeweek/pull/42', $result['result']['pr_url']); + } +}