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 >= GITHUB_API_PAGE_SIZE) { 67 for (const issue of issues) { 68 console.log(issue.title); 69 await validateIssueAsync(issue.number); 70 } 71 issues = await listAllOpenIssuesAsync({ 72 limit: GITHUB_API_PAGE_SIZE, 73 offset: ++page, 74 labels: 'needs validation', 75 }); 76 } 77} 78 79async function validateIssueAsync(issueNumber: number) { 80 const issue = await getIssueAsync(issueNumber); 81 if (!issue) { 82 throw new Error(`Issue #${issueNumber} does not exist.`); 83 } 84 85 // Skip if we already applied some other label 86 for (const label of issue.labels) { 87 const labelName = typeof label === 'string' ? label : label.name; 88 if (labelName && labelName === 'needs validation') { 89 // Remove the validation label since we've started validation 90 console.log('found needs validation label, removing it.'); 91 await removeIssueLabelAsync(issueNumber, 'needs validation'); 92 } else if (labelName && SKIP_VALIDATION_LABELS.includes(labelName)) { 93 console.log(`Issue is labeled with ${labelName}, skipping validation.`); 94 return; 95 } 96 } 97 98 // Maybe actually match the full URL and print it? 99 const matches = REPRO_URI_REGEXES.map((regex) => 100 (issue.body?.toLowerCase() ?? '').match(regex) 101 ).filter(Boolean); 102 103 const includesReproUri = matches.length > 0; 104 105 if (includesReproUri) { 106 console.log('Issue includes a reprodible example URI.'); 107 console.log('adding needs review label.'); 108 await addIssueLabelsAsync(issueNumber, ['needs review']); 109 } else { 110 console.log(issue.labels); 111 if (issue.labels?.includes('needs review')) { 112 console.log('needs review label found, removing it.'); 113 await removeIssueLabelAsync(issueNumber, 'needs review'); 114 } 115 await addIssueLabelsAsync(issueNumber, ['incomplete issue: missing or invalid repro']); 116 console.log('No reproducible example provided, marked for closing.'); 117 } 118} 119