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      try {
91        await removeIssueLabelAsync(issueNumber, 'needs validation');
92      } catch (e) {
93        console.log(e);
94      }
95    } else if (labelName && SKIP_VALIDATION_LABELS.includes(labelName)) {
96      console.log(`Issue is labeled with ${labelName}, skipping validation.`);
97      return;
98    }
99  }
100
101  // Maybe actually match the full URL and print it?
102  const matches = REPRO_URI_REGEXES.map((regex) =>
103    (issue.body?.toLowerCase() ?? '').match(regex)
104  ).filter(Boolean);
105
106  const includesReproUri = matches.length > 0;
107
108  if (includesReproUri) {
109    console.log('Issue includes a reprodible example URI.');
110    console.log('adding needs review label.');
111    await addIssueLabelsAsync(issueNumber, ['needs review']);
112  } else {
113    console.log(issue.labels);
114    if (issue.labels?.includes('needs review')) {
115      console.log('needs review label found, removing it.');
116      try {
117        await removeIssueLabelAsync(issueNumber, 'needs review');
118      } catch (e) {
119        console.log(e);
120      }
121    }
122    await addIssueLabelsAsync(issueNumber, ['incomplete issue: missing or invalid repro']);
123    console.log('No reproducible example provided, marked for closing.');
124  }
125}
126