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