1import { Command } from '@expo/commander';
2
3import {
4  getIssueAsync,
5  listAllOpenIssuesAsync,
6  addIssueLabelsAsync,
7  removeIssueLabelAsync,
8} from '../GitHub';
9import logger from '../Logger';
10
11type ActionOptions = {
12  issue: string;
13};
14
15export default (program: Command) => {
16  program
17    .command('validate-issue')
18    .alias('vi')
19    .description('Verifies whether a GitHub issue is valid.')
20    .option('-i, --issue <string>', 'Number of the issue to validate.')
21    .asyncAction(action);
22};
23
24async function action(options: ActionOptions) {
25  if (options.issue !== '*' && isNaN(Number(options.issue))) {
26    throw new Error('Flag `--issue` must be provided with a number value or *.');
27  }
28  if (!process.env.GITHUB_TOKEN) {
29    throw new Error('Environment variable `GITHUB_TOKEN` is required for this command.');
30  }
31  try {
32    if (options.issue === '*') {
33      await validateAllOpenIssuesAsync();
34    } else {
35      await validateIssueAsync(+options.issue);
36    }
37  } catch (error) {
38    logger.error(error);
39    throw error;
40  }
41}
42
43const REPRO_URI_REGEXES = [
44  /github\.com/,
45  /gitlab\.com/,
46  /bitbucket\.org/,
47  /snack\.expo\.(dev|io)\//,
48];
49
50const SKIP_VALIDATION_LABELS = [
51  '!',
52  'Issue accepted',
53  'invalid issue: feature request',
54  'CLI',
55  'invalid issue: question',
56  'docs',
57];
58
59const GITHUB_API_PAGE_SIZE = 10;
60async function validateAllOpenIssuesAsync() {
61  let issues = await listAllOpenIssuesAsync({
62    limit: GITHUB_API_PAGE_SIZE,
63    labels: 'needs validation',
64  });
65  let page = 0;
66  while (issues.length > 0) {
67    for (const issue of issues) {
68      await validateIssueAsync(issue.number);
69    }
70    issues = await listAllOpenIssuesAsync({
71      limit: GITHUB_API_PAGE_SIZE,
72      offset: ++page,
73      labels: 'needs validation',
74    });
75  }
76}
77
78async function validateIssueAsync(issueNumber: number) {
79  const issue = await getIssueAsync(issueNumber);
80  if (!issue) {
81    throw new Error(`Issue #${issueNumber} does not exist.`);
82  }
83
84  // Skip if we already applied some other label
85  for (const label of issue.labels) {
86    const labelName = typeof label === 'string' ? label : label.name;
87    if (labelName && labelName === 'needs validation') {
88      // Remove the validation label since we've started validation
89      console.log('found needs validation label, removing it.');
90      await removeIssueLabelAsync(issueNumber, 'needs validation');
91    } else if (labelName && SKIP_VALIDATION_LABELS.includes(labelName)) {
92      console.log(`Issue is labeled with ${labelName}, skipping validation.`);
93      return;
94    }
95  }
96
97  // Maybe actually match the full URL and print it?
98  const matches = REPRO_URI_REGEXES.map((regex) =>
99    (issue.body?.toLowerCase() ?? '').match(regex)
100  ).filter(Boolean);
101
102  const includesReproUri = matches.length > 0;
103
104  if (includesReproUri) {
105    console.log('Issue includes a reprodible example URI.');
106    console.log('adding needs review label.');
107    await addIssueLabelsAsync(issueNumber, ['needs review']);
108  } else {
109    console.log(issue.labels);
110    if (issue.labels?.includes('needs review')) {
111      console.log('needs review label found, removing it.');
112      await removeIssueLabelAsync(issueNumber, 'needs review');
113    }
114    await addIssueLabelsAsync(issueNumber, ['incomplete issue: missing or invalid repro']);
115    console.log('No reproducible example provided, marked for closing.');
116  }
117}
118