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