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 bullet points, ignoring the environment section. Keep in mind that this is the user's perspective, and any judgement they share around priority may not match the opinion of maintainers. 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