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