1import { Command } from '@expo/commander'; 2import { UserFilter } from '@linear/sdk/dist/_generated_documents'; 3 4import * as GitHub from '../GitHub'; 5import * as Linear from '../Linear'; 6import logger from '../Logger'; 7import * as OpenAI from '../OpenAI'; 8 9type ActionOptions = { 10 issue: string; 11}; 12 13export default (program: Command) => { 14 program 15 .command('import-github-issue-to-linear') 16 .alias('igitl') 17 .description('Import accepted issues from GitHub to Linear.') 18 .option('-i, --issue <string>', 'Number of the issue to import.') 19 .asyncAction(action); 20}; 21 22async function action(options: ActionOptions) { 23 if (isNaN(Number(options.issue))) { 24 throw new Error('Flag `--issue` must be provided with a number value'); 25 } 26 if (!process.env.GITHUB_TOKEN) { 27 throw new Error('Environment variable `GITHUB_TOKEN` is required for this command.'); 28 } 29 if (!process.env.LINEAR_API_KEY) { 30 throw new Error('Environment variable `LINEAR_API_KEY` is required for this command.'); 31 } 32 33 try { 34 await importIssueAsync(+options.issue); 35 } catch (error) { 36 logger.error(error); 37 throw error; 38 } 39} 40 41async function importIssueAsync(githubIssueNumber: number) { 42 const issue = await GitHub.getIssueAsync(githubIssueNumber); 43 if (!issue) { 44 throw new Error(`Issue #${githubIssueNumber} does not exist.`); 45 } 46 47 let issueSummary: string | undefined; 48 49 try { 50 issueSummary = await OpenAI.askChatGPTAsync( 51 `Provide a brief summary of the following GitHub issue on the Expo repository in 3 to 5 sentences. This summary will be read by the Expo project maintainers,\n${issue.body}` 52 ); 53 } catch (error) { 54 logger.warn('Failed to generate issue summary using OpenAI. Skipping...'); 55 logger.debug(`OpenAI askChatGPTAsync error: ${error}`); 56 } 57 58 const issueDescription = `### This issue was automatically imported from GitHub: ${issue.html_url}\n---\n## Summary:\n${issueSummary}`; 59 60 const githubLabel = await Linear.getOrCreateLabelAsync('GitHub'); 61 const expoSDKLabel = await Linear.getOrCreateLabelAsync('Expo SDK', Linear.ENG_TEAM_ID); 62 const backlogWorkflowState = await Linear.getTeamWorkflowStateAsync( 63 'Backlog', 64 Linear.ENG_TEAM_ID 65 ); 66 67 Linear.createIssueAsync({ 68 title: issue.title, 69 labelIds: [githubLabel.id, expoSDKLabel.id], 70 stateId: backlogWorkflowState.id, 71 description: issueDescription, 72 assigneeId: await inferAssigneeId(issue.assignees), 73 }); 74} 75 76/** 77 * Utility type. Extracts `T` type from `Promise<T>`. 78 */ 79type PromiseType<T extends Promise<any>> = T extends Promise<infer U> ? U : never; 80 81async function inferAssigneeId( 82 githubAssignees: PromiseType<ReturnType<typeof GitHub.getIssueAsync>>['assignees'] 83): Promise<string | undefined> { 84 if (!githubAssignees?.length) { 85 return undefined; 86 } 87 88 const githubUsers = await Promise.all( 89 githubAssignees.map(async ({ login }) => await GitHub.getUserAsync(login)) 90 ); 91 92 const linearUsers = await Linear.getTeamMembersAsync({ 93 teamId: Linear.ENG_TEAM_ID, 94 filter: { 95 or: githubUsers.reduce((acc: UserFilter[], cur) => { 96 acc.push({ displayName: { eqIgnoreCase: cur.login } }); 97 if (cur.name) { 98 acc.push({ name: { containsIgnoreCase: cur.name } }); 99 } 100 if (cur.email) { 101 acc.push({ email: { eq: cur.email } }); 102 } 103 104 return acc; 105 }, []), 106 }, 107 }); 108 109 return linearUsers?.[0]?.id; 110} 111