/ src / commands / install-github-app / setupGitHubActions.ts
setupGitHubActions.ts
  1  import {
  2    type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
  3    logEvent,
  4  } from 'src/services/analytics/index.js'
  5  import { saveGlobalConfig } from 'src/utils/config.js'
  6  import {
  7    CODE_REVIEW_PLUGIN_WORKFLOW_CONTENT,
  8    PR_BODY,
  9    PR_TITLE,
 10    WORKFLOW_CONTENT,
 11  } from '../../constants/github-app.js'
 12  import { openBrowser } from '../../utils/browser.js'
 13  import { execFileNoThrow } from '../../utils/execFileNoThrow.js'
 14  import { logError } from '../../utils/log.js'
 15  import type { Workflow } from './types.js'
 16  
 17  async function createWorkflowFile(
 18    repoName: string,
 19    branchName: string,
 20    workflowPath: string,
 21    workflowContent: string,
 22    secretName: string,
 23    message: string,
 24    context?: {
 25      useCurrentRepo?: boolean
 26      workflowExists?: boolean
 27      secretExists?: boolean
 28    },
 29  ): Promise<void> {
 30    // Check if workflow file already exists
 31    const checkFileResult = await execFileNoThrow('gh', [
 32      'api',
 33      `repos/${repoName}/contents/${workflowPath}`,
 34      '--jq',
 35      '.sha',
 36    ])
 37  
 38    let fileSha: string | null = null
 39    if (checkFileResult.code === 0) {
 40      fileSha = checkFileResult.stdout.trim()
 41    }
 42  
 43    let content = workflowContent
 44    if (secretName === 'CLAUDE_CODE_OAUTH_TOKEN') {
 45      // For OAuth tokens, use the claude_code_oauth_token parameter
 46      content = workflowContent.replace(
 47        /anthropic_api_key: \$\{\{ secrets\.ANTHROPIC_API_KEY \}\}/g,
 48        `claude_code_oauth_token: \${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}`,
 49      )
 50    } else if (secretName !== 'ANTHROPIC_API_KEY') {
 51      // For other custom secret names, keep using anthropic_api_key parameter
 52      content = workflowContent.replace(
 53        /anthropic_api_key: \$\{\{ secrets\.ANTHROPIC_API_KEY \}\}/g,
 54        `anthropic_api_key: \${{ secrets.${secretName} }}`,
 55      )
 56    }
 57    const base64Content = Buffer.from(content).toString('base64')
 58  
 59    const apiParams = [
 60      'api',
 61      '--method',
 62      'PUT',
 63      `repos/${repoName}/contents/${workflowPath}`,
 64      '-f',
 65      `message=${fileSha ? `"Update ${message}"` : `"${message}"`}`,
 66      '-f',
 67      `content=${base64Content}`,
 68      '-f',
 69      `branch=${branchName}`,
 70    ]
 71  
 72    if (fileSha) {
 73      apiParams.push('-f', `sha=${fileSha}`)
 74    }
 75  
 76    const createFileResult = await execFileNoThrow('gh', apiParams)
 77    if (createFileResult.code !== 0) {
 78      if (
 79        createFileResult.stderr.includes('422') &&
 80        createFileResult.stderr.includes('sha')
 81      ) {
 82        logEvent('tengu_setup_github_actions_failed', {
 83          reason:
 84            'failed_to_create_workflow_file' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
 85          exit_code: createFileResult.code,
 86          ...context,
 87        })
 88        throw new Error(
 89          `Failed to create workflow file ${workflowPath}: A Claude workflow file already exists in this repository. Please remove it first or update it manually.`,
 90        )
 91      }
 92  
 93      logEvent('tengu_setup_github_actions_failed', {
 94        reason:
 95          'failed_to_create_workflow_file' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
 96        exit_code: createFileResult.code,
 97        ...context,
 98      })
 99  
100      const helpText =
101        '\n\nNeed help? Common issues:\n' +
102        '· Permission denied → Run: gh auth refresh -h github.com -s repo,workflow\n' +
103        '· Not authorized → Ensure you have admin access to the repository\n' +
104        '· For manual setup → Visit: https://github.com/anthropics/claude-code-action'
105  
106      throw new Error(
107        `Failed to create workflow file ${workflowPath}: ${createFileResult.stderr}${helpText}`,
108      )
109    }
110  }
111  
112  export async function setupGitHubActions(
113    repoName: string,
114    apiKeyOrOAuthToken: string | null,
115    secretName: string,
116    updateProgress: () => void,
117    skipWorkflow = false,
118    selectedWorkflows: Workflow[],
119    authType: 'api_key' | 'oauth_token',
120    context?: {
121      useCurrentRepo?: boolean
122      workflowExists?: boolean
123      secretExists?: boolean
124    },
125  ) {
126    try {
127      logEvent('tengu_setup_github_actions_started', {
128        skip_workflow: skipWorkflow,
129        has_api_key: !!apiKeyOrOAuthToken,
130        using_default_secret_name: secretName === 'ANTHROPIC_API_KEY',
131        selected_claude_workflow: selectedWorkflows.includes('claude'),
132        selected_claude_review_workflow:
133          selectedWorkflows.includes('claude-review'),
134        ...context,
135      })
136  
137      // Check if repository exists
138      const repoCheckResult = await execFileNoThrow('gh', [
139        'api',
140        `repos/${repoName}`,
141        '--jq',
142        '.id',
143      ])
144      if (repoCheckResult.code !== 0) {
145        logEvent('tengu_setup_github_actions_failed', {
146          reason:
147            'repo_not_found' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
148          exit_code: repoCheckResult.code,
149          ...context,
150        })
151        throw new Error(
152          `Failed to access repository ${repoName}: ${repoCheckResult.stderr}`,
153        )
154      }
155  
156      // Get default branch
157      const defaultBranchResult = await execFileNoThrow('gh', [
158        'api',
159        `repos/${repoName}`,
160        '--jq',
161        '.default_branch',
162      ])
163      if (defaultBranchResult.code !== 0) {
164        logEvent('tengu_setup_github_actions_failed', {
165          reason:
166            'failed_to_get_default_branch' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
167          exit_code: defaultBranchResult.code,
168          ...context,
169        })
170        throw new Error(
171          `Failed to get default branch: ${defaultBranchResult.stderr}`,
172        )
173      }
174      const defaultBranch = defaultBranchResult.stdout.trim()
175  
176      // Get SHA of default branch
177      const shaResult = await execFileNoThrow('gh', [
178        'api',
179        `repos/${repoName}/git/ref/heads/${defaultBranch}`,
180        '--jq',
181        '.object.sha',
182      ])
183      if (shaResult.code !== 0) {
184        logEvent('tengu_setup_github_actions_failed', {
185          reason:
186            'failed_to_get_branch_sha' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
187          exit_code: shaResult.code,
188          ...context,
189        })
190        throw new Error(`Failed to get branch SHA: ${shaResult.stderr}`)
191      }
192      const sha = shaResult.stdout.trim()
193  
194      let branchName: string | null = null
195  
196      if (!skipWorkflow) {
197        updateProgress()
198        // Create new branch
199        branchName = `add-claude-github-actions-${Date.now()}`
200        const createBranchResult = await execFileNoThrow('gh', [
201          'api',
202          '--method',
203          'POST',
204          `repos/${repoName}/git/refs`,
205          '-f',
206          `ref=refs/heads/${branchName}`,
207          '-f',
208          `sha=${sha}`,
209        ])
210        if (createBranchResult.code !== 0) {
211          logEvent('tengu_setup_github_actions_failed', {
212            reason:
213              'failed_to_create_branch' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
214            exit_code: createBranchResult.code,
215            ...context,
216          })
217          throw new Error(`Failed to create branch: ${createBranchResult.stderr}`)
218        }
219  
220        updateProgress()
221        // Create selected workflow files
222        const workflows = []
223  
224        if (selectedWorkflows.includes('claude')) {
225          workflows.push({
226            path: '.github/workflows/claude.yml',
227            content: WORKFLOW_CONTENT,
228            message: 'Claude PR Assistant workflow',
229          })
230        }
231  
232        if (selectedWorkflows.includes('claude-review')) {
233          workflows.push({
234            path: '.github/workflows/claude-code-review.yml',
235            content: CODE_REVIEW_PLUGIN_WORKFLOW_CONTENT,
236            message: 'Claude Code Review workflow',
237          })
238        }
239  
240        for (const workflow of workflows) {
241          await createWorkflowFile(
242            repoName,
243            branchName,
244            workflow.path,
245            workflow.content,
246            secretName,
247            workflow.message,
248            context,
249          )
250        }
251      }
252  
253      updateProgress()
254      // Set the API key as a secret if provided
255      if (apiKeyOrOAuthToken) {
256        const setSecretResult = await execFileNoThrow('gh', [
257          'secret',
258          'set',
259          secretName,
260          '--body',
261          apiKeyOrOAuthToken,
262          '--repo',
263          repoName,
264        ])
265        if (setSecretResult.code !== 0) {
266          logEvent('tengu_setup_github_actions_failed', {
267            reason:
268              'failed_to_set_api_key_secret' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
269            exit_code: setSecretResult.code,
270            ...context,
271          })
272  
273          const helpText =
274            '\n\nNeed help? Common issues:\n' +
275            '· Permission denied → Run: gh auth refresh -h github.com -s repo\n' +
276            '· Not authorized → Ensure you have admin access to the repository\n' +
277            '· For manual setup → Visit: https://github.com/anthropics/claude-code-action'
278  
279          throw new Error(
280            `Failed to set API key secret: ${setSecretResult.stderr || 'Unknown error'}${helpText}`,
281          )
282        }
283      }
284  
285      if (!skipWorkflow && branchName) {
286        updateProgress()
287        // Create PR template URL instead of creating PR directly
288        const compareUrl = `https://github.com/${repoName}/compare/${defaultBranch}...${branchName}?quick_pull=1&title=${encodeURIComponent(PR_TITLE)}&body=${encodeURIComponent(PR_BODY)}`
289  
290        await openBrowser(compareUrl)
291      }
292  
293      logEvent('tengu_setup_github_actions_completed', {
294        skip_workflow: skipWorkflow,
295        has_api_key: !!apiKeyOrOAuthToken,
296        auth_type:
297          authType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
298        using_default_secret_name: secretName === 'ANTHROPIC_API_KEY',
299        selected_claude_workflow: selectedWorkflows.includes('claude'),
300        selected_claude_review_workflow:
301          selectedWorkflows.includes('claude-review'),
302        ...context,
303      })
304      saveGlobalConfig(current => ({
305        ...current,
306        githubActionSetupCount: (current.githubActionSetupCount ?? 0) + 1,
307      }))
308    } catch (error) {
309      if (
310        !error ||
311        !(error instanceof Error) ||
312        !error.message.includes('Failed to')
313      ) {
314        logEvent('tengu_setup_github_actions_failed', {
315          reason:
316            'unexpected_error' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
317          ...context,
318        })
319      }
320      if (error instanceof Error) {
321        logError(error)
322      }
323      throw error
324    }
325  }