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