#!/usr/bin/env node // @ts-check /* eslint jsdoc/reject-any-type: off */ /** * @file Auto-close script for closing won't add icons. */ import path from 'node:path'; import process from 'node:process'; /** * @typedef {object} Rule * @property {RegExp[]} patterns - The pattern to match against the issue title. * @property {string} reason - The issue numbers to include in the reason text. */ /** * @typedef {Rule[]} Config */ /** * @typedef {object} Issue * @property {{name: string}[]} labels - Issue labels. * @property {string} state - Issue state, possible values are 'open' and 'closed'. * @property {string} title - Issue title. * @property {string} body - Issue body. */ /** @type {Config} */ const rules = await import( path.join(import.meta.dirname, 'autoclose.rules.js') ).then((module) => module.default); const {GITHUB_TOKEN, GITHUB_REPOSITORY, ISSUE_NUMBER} = process.env; if ([GITHUB_TOKEN, GITHUB_REPOSITORY, ISSUE_NUMBER].some((v) => !v)) { console.error( `${Object.entries({GITHUB_TOKEN, GITHUB_REPOSITORY, ISSUE_NUMBER}) .filter(([, v]) => !v) .map(([k]) => k) .join(', ')} environment variable(s) must be set.`, ); process.exit(1); } /** * Fetch data from GitHub API. * @param {string} url The URL to fetch. * @param {globalThis.RequestInit} options The options to pass to the fetch function. * @returns {Promise} - The response data. */ const githubFetch = async (url, options) => { const response = await globalThis.fetch(new URL(url), { ...options, headers: { Accept: 'application/vnd.github+json', Authorization: `Bearer ${GITHUB_TOKEN}`, 'X-GitHub-Api-Version': '2022-11-28', ...options?.headers, }, }); if (!response.ok) { throw new Error( `Failed to fetch ${url}: ${response.status} (${response.statusText}).`, ); } return response; }; /** * Check if the issue is a won't add icon issue. * GitHub REST API: https://docs.github.com/en/rest/issues/issues?apiVersion=2022-11-28#get-an-issue. * @returns {Promise} - Returns reason if the issue is a won't add icon issue, undefined otherwise. */ const checkIfCanBeClosed = async () => { const url = `https://api.github.com/repos/${GITHUB_REPOSITORY}/issues/${ISSUE_NUMBER}`; const response = await githubFetch(url, {method: 'GET'}); /** @type {Issue} */ const json = await response.json(); const {labels, state, title, body} = json; const labelNames = new Set(labels.map((label) => label.name)); if (state === 'closed') return undefined; if (labelNames.has('meta')) return undefined; if (labelNames.has('in discussion')) return undefined; if (!labelNames.has('new icon')) return undefined; const matched = rules.find((rule) => rule.patterns.some((pattern) => { const brandNamePattern = new RegExp( `### Brand Name\n*${pattern.source.replaceAll('$', '')}\n*###`, 'i', ); return pattern.test(title) || brandNamePattern.test(body); }), ); if (!matched) return undefined; return matched.reason; }; /** * Close the issue as not planned. * GitHub REST API: https://docs.github.com/en/rest/issues/issues?apiVersion=2022-11-28#update-an-issue. */ const closeAsNotPlanned = async () => { const url = `https://api.github.com/repos/${GITHUB_REPOSITORY}/issues/${ISSUE_NUMBER}`; await githubFetch(url, { method: 'PATCH', body: JSON.stringify({ state: 'closed', state_reason: 'not_planned', // eslint-disable-line camelcase }), }); }; /** * Add labels to the issue. * GitHub REST API: https://docs.github.com/en/rest/issues/labels?apiVersion=2022-11-28#add-labels-to-an-issue. */ const addLabels = async () => { const url = `https://api.github.com/repos/${GITHUB_REPOSITORY}/issues/${ISSUE_NUMBER}/labels`; await githubFetch(url, { method: 'POST', body: JSON.stringify({ labels: ['duplicate', "won't add"], }), }); }; /** * Post a comment on the issue. * GitHub REST API: https://docs.github.com/en/rest/issues/comments?apiVersion=2022-11-28#create-an-issue-comment. * @param {string} reason The reason for closing the issue. */ const commentWithReason = async (reason) => { const url = `https://api.github.com/repos/${GITHUB_REPOSITORY}/issues/${ISSUE_NUMBER}/comments`; await githubFetch(url, { method: 'POST', body: JSON.stringify({ body: reason, }), }); }; try { const reason = await checkIfCanBeClosed(); if (reason) { await closeAsNotPlanned(); await addLabels(); await commentWithReason(reason); } } catch (error) { if (error instanceof Error) { console.error(error.message); } else { console.error(String(error)); } process.exit(1); }