Whitelist Request Reminder #3810
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Whitelist Request Reminder | |
| on: | |
| schedule: | |
| - cron: "*/5 * * * *" | |
| workflow_dispatch: | |
| permissions: | |
| contents: read | |
| issues: write | |
| concurrency: | |
| group: whitelist-reminder | |
| cancel-in-progress: false | |
| jobs: | |
| reminder: | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Process incomplete whitelist requests | |
| uses: actions/github-script@v7 | |
| with: | |
| script: | | |
| const PING_INTERVAL_MS = 10 * 60 * 1000; | |
| const MAX_PINGS = 3; | |
| const botMarker = "<!-- dotns-whitelist-bot -->"; | |
| const invalidMarker = "<!-- dotns-whitelist-invalid-attempt -->"; | |
| const pingCountRegex = /<!-- dotns-whitelist-ping-count:(\d+) -->/; | |
| const pingAtRegex = /<!-- dotns-whitelist-ping-at:([^>]+) -->/; | |
| const missingRegex = /<!-- dotns-whitelist-missing:([^>]*) -->/; | |
| const { owner, repo } = context.repo; | |
| function extractField(body, name) { | |
| const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); | |
| const match = (body || "").match(new RegExp(`### ${escaped}\\s*\\n([\\s\\S]*?)(?=\\n### |$)`)); | |
| return match ? match[1].trim() : ""; | |
| } | |
| function findMissingFields(body) { | |
| const address = extractField(body, "Address").split("\n")[0].trim(); | |
| const addressType = extractField(body, "Address Type").split("\n")[0].trim(); | |
| const network = extractField(body, "Network").split("\n")[0].trim(); | |
| const description = extractField(body, "Description").trim(); | |
| const missing = []; | |
| if (!address) missing.push("Address"); | |
| if (!["EVM (H160)", "Substrate (SS58)"].includes(addressType)) missing.push("Address Type"); | |
| if (!["Paseo V2"].includes(network)) missing.push("Network"); | |
| if (description.length < 30) missing.push("Description (at least 30 characters explaining why the bypass is needed)"); | |
| return missing; | |
| } | |
| const allOpenRequests = await github.paginate(github.rest.issues.listForRepo, { | |
| owner, | |
| repo, | |
| state: "open", | |
| labels: "whitelist-request", | |
| per_page: 100, | |
| }); | |
| for (const issue of allOpenRequests) { | |
| if (issue.pull_request) continue; | |
| const labels = (issue.labels || []).map(l => (typeof l === "string" ? l : l.name)); | |
| if (labels.includes("needs-info") || labels.includes("whitelist-rejected")) continue; | |
| const missing = findMissingFields(issue.body); | |
| if (missing.length === 0) continue; | |
| const author = issue.user && issue.user.login; | |
| const missingStr = missing.join(", "); | |
| const lines = [ | |
| botMarker, | |
| invalidMarker, | |
| "<!-- dotns-whitelist-ping-count:1 -->", | |
| `<!-- dotns-whitelist-ping-at:${new Date().toISOString()} -->`, | |
| `<!-- dotns-whitelist-missing:${missingStr} -->`, | |
| "### Whitelist Request Incomplete", | |
| "", | |
| `@${author} this whitelist request is missing required form fields.`, | |
| "", | |
| `**Needs:** ${missingStr}`, | |
| "", | |
| "Please update the issue body so the form fields are correct.", | |
| "", | |
| "**Reminder 1 of 3.** Two further reminders will follow at roughly 10 minute intervals. If the request is still incomplete after the final reminder it will be rejected automatically.", | |
| ]; | |
| await github.rest.issues.createComment({ | |
| owner, | |
| repo, | |
| issue_number: issue.number, | |
| body: lines.join("\n"), | |
| }); | |
| await github.rest.issues.addLabels({ | |
| owner, | |
| repo, | |
| issue_number: issue.number, | |
| labels: ["needs-info"], | |
| }); | |
| if (labels.includes("whitelist-approved")) { | |
| try { | |
| await github.rest.issues.removeLabel({ | |
| owner, | |
| repo, | |
| issue_number: issue.number, | |
| name: "whitelist-approved", | |
| }); | |
| } catch (error) { | |
| if (error.status !== 404) throw error; | |
| } | |
| } | |
| } | |
| const issues = await github.paginate(github.rest.issues.listForRepo, { | |
| owner, | |
| repo, | |
| state: "open", | |
| labels: "whitelist-request,needs-info", | |
| per_page: 100, | |
| }); | |
| for (const issue of issues) { | |
| if (issue.pull_request) continue; | |
| const issue_number = issue.number; | |
| const author = issue.user && issue.user.login; | |
| if (!author) continue; | |
| const comments = await github.paginate(github.rest.issues.listComments, { | |
| owner, | |
| repo, | |
| issue_number, | |
| per_page: 100, | |
| }); | |
| const pingComments = comments.filter(c => | |
| c.body && | |
| pingCountRegex.test(c.body) && | |
| c.user && c.user.type === "Bot" | |
| ); | |
| if (pingComments.length === 0) continue; | |
| const latest = pingComments[pingComments.length - 1]; | |
| const countMatch = latest.body.match(pingCountRegex); | |
| const atMatch = latest.body.match(pingAtRegex); | |
| const missingMatch = latest.body.match(missingRegex); | |
| if (!countMatch || !atMatch) continue; | |
| const count = Number(countMatch[1]); | |
| const lastPingAt = new Date(atMatch[1]); | |
| if (Number.isNaN(lastPingAt.getTime())) continue; | |
| const missing = (missingMatch && missingMatch[1].trim()) || "the required form fields"; | |
| const responded = comments.some(c => | |
| c.user && c.user.login === author && | |
| new Date(c.created_at) > lastPingAt | |
| ); | |
| if (responded) continue; | |
| const elapsed = Date.now() - lastPingAt.getTime(); | |
| if (elapsed < PING_INTERVAL_MS) continue; | |
| if (count < MAX_PINGS) { | |
| const nextCount = count + 1; | |
| const isFinal = nextCount === MAX_PINGS; | |
| const lines = [ | |
| botMarker, | |
| invalidMarker, | |
| `<!-- dotns-whitelist-ping-count:${nextCount} -->`, | |
| `<!-- dotns-whitelist-ping-at:${new Date().toISOString()} -->`, | |
| `<!-- dotns-whitelist-missing:${missing} -->`, | |
| `### Whitelist Request Reminder ${nextCount} of ${MAX_PINGS}`, | |
| "", | |
| `@${author} this whitelist request is still incomplete.`, | |
| "", | |
| `**Needs:** ${missing}`, | |
| "", | |
| isFinal | |
| ? "**Final reminder.** If this request is still incomplete by the next check the request will be rejected automatically." | |
| : `Please update the issue body so the form fields are correct. ${MAX_PINGS - nextCount} further reminder${MAX_PINGS - nextCount === 1 ? "" : "s"} will follow before rejection.`, | |
| ]; | |
| await github.rest.issues.createComment({ | |
| owner, | |
| repo, | |
| issue_number, | |
| body: lines.join("\n"), | |
| }); | |
| } else { | |
| const lines = [ | |
| botMarker, | |
| "<!-- dotns-whitelist-rejected -->", | |
| `<!-- dotns-whitelist-rejected-at:${new Date().toISOString()} -->`, | |
| `<!-- dotns-whitelist-missing:${missing} -->`, | |
| "### Whitelist Request Rejected", | |
| "", | |
| `@${author} after 3 reminders without an update from you, this whitelist request has been rejected automatically.`, | |
| "", | |
| `**Reason:** the request was still missing required form fields after the final reminder.`, | |
| `**Missing at time of rejection:** ${missing}`, | |
| "", | |
| "The issue is being left open so the rejection reason stays visible. If you would like to whitelist this address you can open a new request with the complete form.", | |
| ]; | |
| await github.rest.issues.createComment({ | |
| owner, | |
| repo, | |
| issue_number, | |
| body: lines.join("\n"), | |
| }); | |
| for (const label of ["whitelist-request", "whitelist-approved", "needs-info"]) { | |
| try { | |
| await github.rest.issues.removeLabel({ owner, repo, issue_number, name: label }); | |
| } catch (error) { | |
| if (error.status !== 404) throw error; | |
| } | |
| } | |
| await github.rest.issues.addLabels({ | |
| owner, | |
| repo, | |
| issue_number, | |
| labels: ["whitelist-rejected"], | |
| }); | |
| } | |
| } |