| import { |
| type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, |
| logEvent, |
| } from 'src/services/analytics/index.js' |
| import { saveGlobalConfig } from 'src/utils/config.js' |
| import { |
| CODE_REVIEW_PLUGIN_WORKFLOW_CONTENT, |
| PR_BODY, |
| PR_TITLE, |
| WORKFLOW_CONTENT, |
| } from '../../constants/github-app.js' |
| import { openBrowser } from '../../utils/browser.js' |
| import { execFileNoThrow } from '../../utils/execFileNoThrow.js' |
| import { logError } from '../../utils/log.js' |
| import type { Workflow } from './types.js' |
|
|
| async function createWorkflowFile( |
| repoName: string, |
| branchName: string, |
| workflowPath: string, |
| workflowContent: string, |
| secretName: string, |
| message: string, |
| context?: { |
| useCurrentRepo?: boolean |
| workflowExists?: boolean |
| secretExists?: boolean |
| }, |
| ): Promise<void> { |
| |
| const checkFileResult = await execFileNoThrow('gh', [ |
| 'api', |
| `repos/${repoName}/contents/${workflowPath}`, |
| '--jq', |
| '.sha', |
| ]) |
|
|
| let fileSha: string | null = null |
| if (checkFileResult.code === 0) { |
| fileSha = checkFileResult.stdout.trim() |
| } |
|
|
| let content = workflowContent |
| if (secretName === 'CLAUDE_CODE_OAUTH_TOKEN') { |
| |
| content = workflowContent.replace( |
| /anthropic_api_key: \$\{\{ secrets\.ANTHROPIC_API_KEY \}\}/g, |
| `claude_code_oauth_token: \${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}`, |
| ) |
| } else if (secretName !== 'ANTHROPIC_API_KEY') { |
| |
| content = workflowContent.replace( |
| /anthropic_api_key: \$\{\{ secrets\.ANTHROPIC_API_KEY \}\}/g, |
| `anthropic_api_key: \${{ secrets.${secretName} }}`, |
| ) |
| } |
| const base64Content = Buffer.from(content).toString('base64') |
|
|
| const apiParams = [ |
| 'api', |
| '--method', |
| 'PUT', |
| `repos/${repoName}/contents/${workflowPath}`, |
| '-f', |
| `message=${fileSha ? `"Update ${message}"` : `"${message}"`}`, |
| '-f', |
| `content=${base64Content}`, |
| '-f', |
| `branch=${branchName}`, |
| ] |
|
|
| if (fileSha) { |
| apiParams.push('-f', `sha=${fileSha}`) |
| } |
|
|
| const createFileResult = await execFileNoThrow('gh', apiParams) |
| if (createFileResult.code !== 0) { |
| if ( |
| createFileResult.stderr.includes('422') && |
| createFileResult.stderr.includes('sha') |
| ) { |
| logEvent('tengu_setup_github_actions_failed', { |
| reason: |
| 'failed_to_create_workflow_file' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, |
| exit_code: createFileResult.code, |
| ...context, |
| }) |
| throw new Error( |
| `Failed to create workflow file ${workflowPath}: A Claude workflow file already exists in this repository. Please remove it first or update it manually.`, |
| ) |
| } |
|
|
| logEvent('tengu_setup_github_actions_failed', { |
| reason: |
| 'failed_to_create_workflow_file' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, |
| exit_code: createFileResult.code, |
| ...context, |
| }) |
|
|
| const helpText = |
| '\n\nNeed help? Common issues:\n' + |
| '· Permission denied → Run: gh auth refresh -h github.com -s repo,workflow\n' + |
| '· Not authorized → Ensure you have admin access to the repository\n' + |
| '· For manual setup → Visit: https://github.com/anthropics/claude-code-action' |
|
|
| throw new Error( |
| `Failed to create workflow file ${workflowPath}: ${createFileResult.stderr}${helpText}`, |
| ) |
| } |
| } |
|
|
| export async function setupGitHubActions( |
| repoName: string, |
| apiKeyOrOAuthToken: string | null, |
| secretName: string, |
| updateProgress: () => void, |
| skipWorkflow = false, |
| selectedWorkflows: Workflow[], |
| authType: 'api_key' | 'oauth_token', |
| context?: { |
| useCurrentRepo?: boolean |
| workflowExists?: boolean |
| secretExists?: boolean |
| }, |
| ) { |
| try { |
| logEvent('tengu_setup_github_actions_started', { |
| skip_workflow: skipWorkflow, |
| has_api_key: !!apiKeyOrOAuthToken, |
| using_default_secret_name: secretName === 'ANTHROPIC_API_KEY', |
| selected_claude_workflow: selectedWorkflows.includes('claude'), |
| selected_claude_review_workflow: |
| selectedWorkflows.includes('claude-review'), |
| ...context, |
| }) |
|
|
| |
| const repoCheckResult = await execFileNoThrow('gh', [ |
| 'api', |
| `repos/${repoName}`, |
| '--jq', |
| '.id', |
| ]) |
| if (repoCheckResult.code !== 0) { |
| logEvent('tengu_setup_github_actions_failed', { |
| reason: |
| 'repo_not_found' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, |
| exit_code: repoCheckResult.code, |
| ...context, |
| }) |
| throw new Error( |
| `Failed to access repository ${repoName}: ${repoCheckResult.stderr}`, |
| ) |
| } |
|
|
| |
| const defaultBranchResult = await execFileNoThrow('gh', [ |
| 'api', |
| `repos/${repoName}`, |
| '--jq', |
| '.default_branch', |
| ]) |
| if (defaultBranchResult.code !== 0) { |
| logEvent('tengu_setup_github_actions_failed', { |
| reason: |
| 'failed_to_get_default_branch' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, |
| exit_code: defaultBranchResult.code, |
| ...context, |
| }) |
| throw new Error( |
| `Failed to get default branch: ${defaultBranchResult.stderr}`, |
| ) |
| } |
| const defaultBranch = defaultBranchResult.stdout.trim() |
|
|
| |
| const shaResult = await execFileNoThrow('gh', [ |
| 'api', |
| `repos/${repoName}/git/ref/heads/${defaultBranch}`, |
| '--jq', |
| '.object.sha', |
| ]) |
| if (shaResult.code !== 0) { |
| logEvent('tengu_setup_github_actions_failed', { |
| reason: |
| 'failed_to_get_branch_sha' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, |
| exit_code: shaResult.code, |
| ...context, |
| }) |
| throw new Error(`Failed to get branch SHA: ${shaResult.stderr}`) |
| } |
| const sha = shaResult.stdout.trim() |
|
|
| let branchName: string | null = null |
|
|
| if (!skipWorkflow) { |
| updateProgress() |
| |
| branchName = `add-claude-github-actions-${Date.now()}` |
| const createBranchResult = await execFileNoThrow('gh', [ |
| 'api', |
| '--method', |
| 'POST', |
| `repos/${repoName}/git/refs`, |
| '-f', |
| `ref=refs/heads/${branchName}`, |
| '-f', |
| `sha=${sha}`, |
| ]) |
| if (createBranchResult.code !== 0) { |
| logEvent('tengu_setup_github_actions_failed', { |
| reason: |
| 'failed_to_create_branch' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, |
| exit_code: createBranchResult.code, |
| ...context, |
| }) |
| throw new Error(`Failed to create branch: ${createBranchResult.stderr}`) |
| } |
|
|
| updateProgress() |
| |
| const workflows = [] |
|
|
| if (selectedWorkflows.includes('claude')) { |
| workflows.push({ |
| path: '.github/workflows/claude.yml', |
| content: WORKFLOW_CONTENT, |
| message: 'Claude PR Assistant workflow', |
| }) |
| } |
|
|
| if (selectedWorkflows.includes('claude-review')) { |
| workflows.push({ |
| path: '.github/workflows/claude-code-review.yml', |
| content: CODE_REVIEW_PLUGIN_WORKFLOW_CONTENT, |
| message: 'Claude Code Review workflow', |
| }) |
| } |
|
|
| for (const workflow of workflows) { |
| await createWorkflowFile( |
| repoName, |
| branchName, |
| workflow.path, |
| workflow.content, |
| secretName, |
| workflow.message, |
| context, |
| ) |
| } |
| } |
|
|
| updateProgress() |
| |
| if (apiKeyOrOAuthToken) { |
| const setSecretResult = await execFileNoThrow('gh', [ |
| 'secret', |
| 'set', |
| secretName, |
| '--body', |
| apiKeyOrOAuthToken, |
| '--repo', |
| repoName, |
| ]) |
| if (setSecretResult.code !== 0) { |
| logEvent('tengu_setup_github_actions_failed', { |
| reason: |
| 'failed_to_set_api_key_secret' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, |
| exit_code: setSecretResult.code, |
| ...context, |
| }) |
|
|
| const helpText = |
| '\n\nNeed help? Common issues:\n' + |
| '· Permission denied → Run: gh auth refresh -h github.com -s repo\n' + |
| '· Not authorized → Ensure you have admin access to the repository\n' + |
| '· For manual setup → Visit: https://github.com/anthropics/claude-code-action' |
|
|
| throw new Error( |
| `Failed to set API key secret: ${setSecretResult.stderr || 'Unknown error'}${helpText}`, |
| ) |
| } |
| } |
|
|
| if (!skipWorkflow && branchName) { |
| updateProgress() |
| |
| const compareUrl = `https://github.com/${repoName}/compare/${defaultBranch}...${branchName}?quick_pull=1&title=${encodeURIComponent(PR_TITLE)}&body=${encodeURIComponent(PR_BODY)}` |
|
|
| await openBrowser(compareUrl) |
| } |
|
|
| logEvent('tengu_setup_github_actions_completed', { |
| skip_workflow: skipWorkflow, |
| has_api_key: !!apiKeyOrOAuthToken, |
| auth_type: |
| authType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, |
| using_default_secret_name: secretName === 'ANTHROPIC_API_KEY', |
| selected_claude_workflow: selectedWorkflows.includes('claude'), |
| selected_claude_review_workflow: |
| selectedWorkflows.includes('claude-review'), |
| ...context, |
| }) |
| saveGlobalConfig(current => ({ |
| ...current, |
| githubActionSetupCount: (current.githubActionSetupCount ?? 0) + 1, |
| })) |
| } catch (error) { |
| if ( |
| !error || |
| !(error instanceof Error) || |
| !error.message.includes('Failed to') |
| ) { |
| logEvent('tengu_setup_github_actions_failed', { |
| reason: |
| 'unexpected_error' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, |
| ...context, |
| }) |
| } |
| if (error instanceof Error) { |
| logError(error) |
| } |
| throw error |
| } |
| } |
|
|