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