Skip to content
This repository was archived by the owner on Aug 10, 2026. It is now read-only.

Issue Validator

Issue Validator #4

name: Issue Validator
on:
issues:
types: [opened, edited]
jobs:
validate:
runs-on: ubuntu-latest
permissions:
issues: write
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Validate Issue Format
uses: actions/github-script@v7
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const fs = require('fs');
const path = require('path');
const issue = context.payload.issue;
const title = (issue.title || '').trim();
const body = (issue.body || '').trim();
let errors = [];
const MAX_LEN = 60;
if (title.length > MAX_LEN) {
errors.push(`- Issue title is too long (${title.length} chars). Maximum allowed: ${MAX_LEN}.`);
}
if (title !== body) {
errors.push('- Title and body must be exactly identical.');
}
const RE = /^<\s*HeroeName\s*\|\s*([a-zA-Z\u0400-\u04FF0-9_ -]{1,15})\s*(?:\|\s*(#[0-9a-fA-F]{3,6})\s*)?\s*>$/i;
const match = title.length <= MAX_LEN ? title.match(RE) : null;
let hasColor = false;
let heroName = '';
if (!match) {
if (title.length <= MAX_LEN) {
errors.push(
'- The content must be exactly `<HeroeName|Username>` or `<HeroeName|Username|#RRGGBB>` with no extra text.'
);
}
} else {
heroName = match[1].trim();
const color = match[2];
if (!heroName || heroName.length === 0) {
errors.push('- Username cannot be empty.');
}
if (heroName.length > 15) {
errors.push(`- Username is too long (${heroName.length} chars). Maximum: 15.`);
}
if (/[^a-zA-Z\u0400-\u04FF0-9_ -]/.test(heroName)) {
errors.push('- Username contains invalid characters. Only letters (latin/cyrillic), numbers, spaces, underscores, and hyphens are allowed.');
}
if (color !== undefined) {
hasColor = true;
if (!/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(color)) {
errors.push(`- Color \`${color}\` is not valid. Use \`#RGB\` or \`#RRGGBB\` (e.g. \`#FF0000\`).`);
}
}
const bannedDir = path.join(process.env.GITHUB_WORKSPACE, 'banned-words');
if (fs.existsSync(bannedDir) && fs.statSync(bannedDir).isDirectory()) {
const files = fs.readdirSync(bannedDir).filter(f => f.endsWith('.txt'));
const patterns = [];
for (const file of files) {
const content = fs.readFileSync(path.join(bannedDir, file), 'utf8');
content
.split('\n')
.map(l => l.trim().toLowerCase())
.filter(Boolean)
.forEach(line => patterns.push(line));
}
const input = heroName.toLowerCase();
function matchPattern(pattern, str) {
if (pattern.includes('&&')) {
const parts = pattern.split('&&').map(p => p.trim()).filter(Boolean);
return parts.every(p => matchPattern(p, str));
}
if (pattern.includes('*') || pattern.includes('+')) {
const reStr = pattern
.replace(/[.^${}()|[\]\\]/g, '\\$&')
.replace(/\*/g, '[\\s\\S]*')
.replace(/\+/g, '[^\\s]+');
const startsWild = pattern[0] === '*' || pattern[0] === '+';
const endsWild = pattern[pattern.length - 1] === '*' || pattern[pattern.length - 1] === '+';
let re;
if (startsWild || endsWild) {
try { re = new RegExp(reStr, 'i'); } catch { return false; }
return re.test(str);
} else {
try { re = new RegExp('^' + reStr + '$', 'i'); } catch { return false; }
return re.test(str);
}
}
return str === pattern;
}
const found = patterns.find(p => matchPattern(p, input));
if (found) {
errors.push('- Username contains a banned word.');
}
}
}
if (errors.length === 0) {
const login = issue.user.login;
const allIssues = await github.paginate(github.rest.issues.listForRepo, {
owner: context.repo.owner,
repo: context.repo.repo,
state: 'all',
per_page: 100,
});
const RE_CHECK = /^<\s*HeroeName\s*\|\s*([a-zA-Z\u0400-\u04FF0-9_ -]{1,15})\s*(?:\|\s*(#[0-9a-fA-F]{3,6})\s*)?\s*>$/i;
const validFromAuthor = allIssues.filter(i =>
i.number !== issue.number &&
i.user?.login === login &&
(i.title || '').trim().length <= MAX_LEN &&
RE_CHECK.test((i.title || '').trim()) &&
i.labels.some(l => l.name === 'Valid')
);
if (validFromAuthor.length >= 2) {
errors.push(
`- You already have **${validFromAuthor.length}** accepted issues. Maximum per user is **2**. Your existing accepted issues remain valid.`
);
}
}
for (const [name, color] of [['Valid', '0E8A16'], ['Invalid', 'D93F0B']]) {
try {
await github.rest.issues.getLabel({
owner: context.repo.owner, repo: context.repo.repo, name
});
} catch (_) {
await github.rest.issues.createLabel({
owner: context.repo.owner, repo: context.repo.repo, name, color
});
}
}
const isValid = errors.length === 0;
if (isValid) {
await github.rest.issues.addLabels({
owner: context.repo.owner, repo: context.repo.repo,
issue_number: issue.number, labels: ['Valid']
});
try {
await github.rest.issues.removeLabel({
owner: context.repo.owner, repo: context.repo.repo,
issue_number: issue.number, name: 'Invalid'
});
} catch (_) {}
let commentBody = '✅ **Validation passed!** Your username will be added to the badge.';
if (!hasColor && heroName) {
commentBody += '\n\n💡 **Tip — want a custom color?**\nYou can also specify your own color by editing the issue to:\n```\n<HeroeName|' + heroName + '|#FF0000>\n```\nReplace `#FF0000` with any hex color you like (e.g. `#00FFAA`, `#3f88e6`).\nWithout a color, a random one is assigned automatically 🎨';
}
await github.rest.issues.createComment({
owner: context.repo.owner, repo: context.repo.repo,
issue_number: issue.number, body: commentBody
});
} else {
await github.rest.issues.addLabels({
owner: context.repo.owner, repo: context.repo.repo,
issue_number: issue.number, labels: ['Invalid']
});
try {
await github.rest.issues.removeLabel({
owner: context.repo.owner, repo: context.repo.repo,
issue_number: issue.number, name: 'Valid'
});
} catch (_) {}
await github.rest.issues.createComment({
owner: context.repo.owner, repo: context.repo.repo,
issue_number: issue.number,
body: '❌ **Validation failed.** Please edit your issue to fix the following:\n\n' + errors.join('\n')
});
}