diff --git a/.env.example b/.env.example index 8965068dce2..fc26766e90e 100644 --- a/.env.example +++ b/.env.example @@ -30,4 +30,11 @@ BUILD_LOCALES= # If resource constraints are being hit during builds, change LIMIT_CPUS to a # fixed number of CPUs (e.g. 2) to limit the demand during build time -LIMIT_CPUS= \ No newline at end of file +LIMIT_CPUS= + +# Discord environment (ID and token required for Discord webhook call) +# DISCORD_ID= +# DISCORD_TOKEN= + +# Github token used for fetching good first issues +# ISSUES_GITHUB_TOKEN_READ_ONLY diff --git a/.github/workflows/gfi-discord-webhook.yml b/.github/workflows/gfi-discord-webhook.yml new file mode 100644 index 00000000000..8457b1f0519 --- /dev/null +++ b/.github/workflows/gfi-discord-webhook.yml @@ -0,0 +1,22 @@ +name: GFI Discord webhook + +on: + schedule: + - cron: "0 * * * *" # Runs at the start of every hour + workflow_dispatch: + +jobs: + build: + name: GFI Discord webhook + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - uses: actions/setup-node@v3 + with: + node-version: 18 + - run: yarn install + - run: yarn discord-issues + env: + DISCORD_ID: ${{ secrets.DISCORD_ID }} + DISCORD_TOKEN: ${{ secrets.DISCORD_TOKEN }} + ISSUES_GITHUB_TOKEN: ${{ secrets.ISSUES_GITHUB_TOKEN_READ_ONLY }} diff --git a/package.json b/package.json index 5b788ca9132..b1fb31b9e5d 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,8 @@ "chromatic": "chromatic --project-token fee8e66c9916", "crowdin-clean": "rm -rf .crowdin && mkdir .crowdin", "crowdin-import": "ts-node src/scripts/crowdin-import.ts", - "markdown-checker": "ts-node -O '{ \"module\": \"commonjs\" }' src/scripts/markdownChecker.ts" + "markdown-checker": "ts-node -O '{ \"module\": \"commonjs\" }' src/scripts/markdownChecker.ts", + "discord-issues": "ts-node -O '{ \"module\": \"commonjs\" }' src/scripts/gfi-discord-webhook" }, "dependencies": { "@chakra-ui/react": "^2.8.0", diff --git a/src/lib/api/fetchGFIs.ts b/src/lib/api/fetchGFIs.ts new file mode 100644 index 00000000000..b6b68b28969 --- /dev/null +++ b/src/lib/api/fetchGFIs.ts @@ -0,0 +1,45 @@ +const owner = "ethereum" +const repo = "ethereum-org-website" +const label = "good first issue" + +type GHIssue = { + title: string + html_url: string + created_at: string + user: { + login: string + html_url: string + avatar_url: string + } + labels: GHLabel[] +} + +type GHLabel = { + name: string +} + +export const fetchGFIs = async (since: string) => { + const url = `https://api.github.com/repos/${owner}/${repo}/issues?labels=${encodeURIComponent( + label + )}&since=${since}&state=open&sort=created&direction=desc` + + try { + const response = await fetch(url, { + headers: { + Authorization: `token ${process.env.ISSUES_GITHUB_TOKEN_READ_ONLY}`, + Accept: "application/vnd.github.v3+json", + }, + }) + + if (!response.ok) { + throw new Error( + `GitHub API responded with ${response.status}: ${response.statusText}` + ) + } + + return (await response.json()) as GHIssue[] + } catch (error) { + console.error(error) + return [] + } +} diff --git a/src/lib/utils/gh.ts b/src/lib/utils/gh.ts index 617d74b024e..4db62d4e1f3 100644 --- a/src/lib/utils/gh.ts +++ b/src/lib/utils/gh.ts @@ -44,3 +44,35 @@ export const getLastModifiedDateByPath = (path: string): string => { const logInfo = getGitLogFromPath(path) return extractDateFromGitLogInfo(logInfo) } + +const LABELS_TO_SEARCH = ["content", "design", "dev", "doc", "translation"] +const LABELS_TO_TEXT = { + content: "content", + design: "design", + dev: "dev", + doc: "docs", + translation: "translation", +} + +// Given a list of labels, it returns a string with the labels that match the +// LABELS_TO_SEARCH list, using the LABELS_TO_TEXT values +// Example: +// - ["content :pencil:", "ux design"] => "content, design" +// - ["documentation :emoji:", "dev required", "good first issue"] => "docs, dev" +export const rawLabelsToText = (labels: string[]) => { + return labels + .map((label) => { + const labelIndex = LABELS_TO_SEARCH.findIndex((l) => + label.toLocaleLowerCase().includes(l) + ) + + if (labelIndex === -1) { + return + } + + const labelMatched = LABELS_TO_SEARCH[labelIndex] + return LABELS_TO_TEXT[labelMatched] + }) + .filter(Boolean) + .join(", ") +} diff --git a/src/scripts/gfi-discord-webhook.ts b/src/scripts/gfi-discord-webhook.ts new file mode 100644 index 00000000000..a441f5018c7 --- /dev/null +++ b/src/scripts/gfi-discord-webhook.ts @@ -0,0 +1,71 @@ +import * as dotenv from "dotenv" + +import { rawLabelsToText } from "@/lib/utils/gh" + +import { fetchGFIs } from "../lib/api/fetchGFIs" + +dotenv.config({ path: `.env.local` }) + +const run = async () => { + // Calculate the start of the last hour + const now = new Date() + const sinceDate = new Date( + now.getFullYear(), + now.getMonth(), + now.getDate(), + now.getHours() - 1 + ).toISOString() + + const issues = await fetchGFIs(sinceDate) + + if (!issues.length) { + console.log("No new good first issues found.") + return + } + + const embeds = issues.map((issue) => ({ + title: issue.title, + url: issue.html_url, + timestamp: issue.created_at, + description: issue.labels.map((label) => label.name).join(" • "), + color: 10181046, // purple + author: { + name: issue.user.login, + url: issue.user.html_url, + icon_url: issue.user.avatar_url, + }, + })) + + const allLabels = issues + .map((issue) => issue.labels.map((label) => label.name)) + .flat() + const uniqueLabels = Array.from(new Set(allLabels)) + const labels = rawLabelsToText(uniqueLabels) + const labelsText = labels ? ` - ${labels}` : "" + + const message = { + content: + issues.length > 1 + ? `## (${issues.length}) New good first issues${labelsText}` + : `## New good first issue${labelsText}`, + embeds, + } + + const webhookUrl = `https://discord.com/api/webhooks/${process.env.DISCORD_ID}/${process.env.DISCORD_TOKEN}` + + const res = await fetch(webhookUrl, { + method: "post", + body: JSON.stringify(message), + headers: { "Content-Type": "application/json" }, + }) + + if (!res.ok) { + const error = await res.json() + console.log(error, res) + throw new Error(`Error: ${res.status} ${res.statusText}`) + } + + console.log("Message sent successfully!") +} + +run()