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