1c06f4600SBrent Vatneimport { Command } from '@expo/commander';
2c06f4600SBrent Vatne
3819ec002SBrent Vatneimport {
4819ec002SBrent Vatne  getIssueAsync,
5819ec002SBrent Vatne  listAllOpenIssuesAsync,
6819ec002SBrent Vatne  addIssueLabelsAsync,
7819ec002SBrent Vatne  removeIssueLabelAsync,
8819ec002SBrent Vatne} from '../GitHub';
9c06f4600SBrent Vatneimport logger from '../Logger';
10c06f4600SBrent Vatne
11c06f4600SBrent Vatnetype ActionOptions = {
12c06f4600SBrent Vatne  issue: string;
13c06f4600SBrent Vatne};
14c06f4600SBrent Vatne
15c06f4600SBrent Vatneexport default (program: Command) => {
16c06f4600SBrent Vatne  program
17c06f4600SBrent Vatne    .command('validate-issue')
18c06f4600SBrent Vatne    .alias('vi')
19c06f4600SBrent Vatne    .description('Verifies whether a GitHub issue is valid.')
20c06f4600SBrent Vatne    .option('-i, --issue <string>', 'Number of the issue to validate.')
21c06f4600SBrent Vatne    .asyncAction(action);
22c06f4600SBrent Vatne};
23c06f4600SBrent Vatne
24c06f4600SBrent Vatneasync function action(options: ActionOptions) {
258be0d032SBrent Vatne  if (options.issue !== '*' && isNaN(Number(options.issue))) {
26819ec002SBrent Vatne    throw new Error('Flag `--issue` must be provided with a number value or *.');
27c06f4600SBrent Vatne  }
28c06f4600SBrent Vatne  if (!process.env.GITHUB_TOKEN) {
29c06f4600SBrent Vatne    throw new Error('Environment variable `GITHUB_TOKEN` is required for this command.');
30c06f4600SBrent Vatne  }
31c06f4600SBrent Vatne  try {
32819ec002SBrent Vatne    if (options.issue === '*') {
33819ec002SBrent Vatne      await validateAllOpenIssuesAsync();
34819ec002SBrent Vatne    } else {
35c06f4600SBrent Vatne      await validateIssueAsync(+options.issue);
36819ec002SBrent Vatne    }
37c06f4600SBrent Vatne  } catch (error) {
38c06f4600SBrent Vatne    logger.error(error);
39c06f4600SBrent Vatne    throw error;
40c06f4600SBrent Vatne  }
41c06f4600SBrent Vatne}
42c06f4600SBrent Vatne
43c06f4600SBrent Vatneconst REPRO_URI_REGEXES = [
44c06f4600SBrent Vatne  /github\.com/,
45c06f4600SBrent Vatne  /gitlab\.com/,
46c06f4600SBrent Vatne  /bitbucket\.org/,
47c06f4600SBrent Vatne  /snack\.expo\.(dev|io)\//,
48c06f4600SBrent Vatne];
49c06f4600SBrent Vatne
50c06f4600SBrent Vatneconst SKIP_VALIDATION_LABELS = [
51c06f4600SBrent Vatne  '!',
52c06f4600SBrent Vatne  'Issue accepted',
53c06f4600SBrent Vatne  'invalid issue: feature request',
54c06f4600SBrent Vatne  'CLI',
55c06f4600SBrent Vatne  'invalid issue: question',
56c06f4600SBrent Vatne  'docs',
57c06f4600SBrent Vatne];
58c06f4600SBrent Vatne
59819ec002SBrent Vatneconst GITHUB_API_PAGE_SIZE = 10;
60819ec002SBrent Vatneasync function validateAllOpenIssuesAsync() {
61819ec002SBrent Vatne  let issues = await listAllOpenIssuesAsync({
62819ec002SBrent Vatne    limit: GITHUB_API_PAGE_SIZE,
63819ec002SBrent Vatne    labels: 'needs validation',
64819ec002SBrent Vatne  });
65819ec002SBrent Vatne  let page = 0;
66c15869bcSBrent Vatne  while (issues.length > 0) {
67819ec002SBrent Vatne    for (const issue of issues) {
68819ec002SBrent Vatne      await validateIssueAsync(issue.number);
69819ec002SBrent Vatne    }
70819ec002SBrent Vatne    issues = await listAllOpenIssuesAsync({
71819ec002SBrent Vatne      limit: GITHUB_API_PAGE_SIZE,
72819ec002SBrent Vatne      offset: ++page,
73819ec002SBrent Vatne      labels: 'needs validation',
74819ec002SBrent Vatne    });
75819ec002SBrent Vatne  }
76819ec002SBrent Vatne}
77819ec002SBrent Vatne
78c06f4600SBrent Vatneasync function validateIssueAsync(issueNumber: number) {
79c06f4600SBrent Vatne  const issue = await getIssueAsync(issueNumber);
80c06f4600SBrent Vatne  if (!issue) {
81c06f4600SBrent Vatne    throw new Error(`Issue #${issueNumber} does not exist.`);
82c06f4600SBrent Vatne  }
83c06f4600SBrent Vatne
84c06f4600SBrent Vatne  // Skip if we already applied some other label
85c06f4600SBrent Vatne  for (const label of issue.labels) {
86c06f4600SBrent Vatne    const labelName = typeof label === 'string' ? label : label.name;
87819ec002SBrent Vatne    if (labelName && labelName === 'needs validation') {
88819ec002SBrent Vatne      // Remove the validation label since we've started validation
89819ec002SBrent Vatne      console.log('found needs validation label, removing it.');
90*41afa882SBrent Vatne      try {
91819ec002SBrent Vatne        await removeIssueLabelAsync(issueNumber, 'needs validation');
92*41afa882SBrent Vatne      } catch (e) {
93*41afa882SBrent Vatne        console.log(e);
94*41afa882SBrent Vatne      }
95819ec002SBrent Vatne    } else if (labelName && SKIP_VALIDATION_LABELS.includes(labelName)) {
96c06f4600SBrent Vatne      console.log(`Issue is labeled with ${labelName}, skipping validation.`);
97c06f4600SBrent Vatne      return;
98c06f4600SBrent Vatne    }
99c06f4600SBrent Vatne  }
100c06f4600SBrent Vatne
101c06f4600SBrent Vatne  // Maybe actually match the full URL and print it?
102c06f4600SBrent Vatne  const matches = REPRO_URI_REGEXES.map((regex) =>
103c06f4600SBrent Vatne    (issue.body?.toLowerCase() ?? '').match(regex)
104c06f4600SBrent Vatne  ).filter(Boolean);
105c06f4600SBrent Vatne
106c06f4600SBrent Vatne  const includesReproUri = matches.length > 0;
107c06f4600SBrent Vatne
108c06f4600SBrent Vatne  if (includesReproUri) {
109c06f4600SBrent Vatne    console.log('Issue includes a reprodible example URI.');
110819ec002SBrent Vatne    console.log('adding needs review label.');
111819ec002SBrent Vatne    await addIssueLabelsAsync(issueNumber, ['needs review']);
112c06f4600SBrent Vatne  } else {
113819ec002SBrent Vatne    console.log(issue.labels);
114819ec002SBrent Vatne    if (issue.labels?.includes('needs review')) {
115819ec002SBrent Vatne      console.log('needs review label found, removing it.');
116*41afa882SBrent Vatne      try {
117819ec002SBrent Vatne        await removeIssueLabelAsync(issueNumber, 'needs review');
118*41afa882SBrent Vatne      } catch (e) {
119*41afa882SBrent Vatne        console.log(e);
120*41afa882SBrent Vatne      }
121819ec002SBrent Vatne    }
122819ec002SBrent Vatne    await addIssueLabelsAsync(issueNumber, ['incomplete issue: missing or invalid repro']);
123c06f4600SBrent Vatne    console.log('No reproducible example provided, marked for closing.');
124c06f4600SBrent Vatne  }
125c06f4600SBrent Vatne}
126