Skip to content

[Resource]: Hedgehog - An opinionated AI software engineering workflow built for BMAD #681

[Resource]: Hedgehog - An opinionated AI software engineering workflow built for BMAD

[Resource]: Hedgehog - An opinionated AI software engineering workflow built for BMAD #681

name: Validate Resource Submission
on:
issues:
types: [opened, edited]
permissions:
contents: read
jobs:
# Enforces the CONTRIBUTING eligibility conditions before the rest of
# validation runs. Errors pass the submission through rather than closing it.
eligibility:
name: Check submission eligibility
if: github.event.action == 'opened' && contains(github.event.issue.labels.*.name, 'resource-submission')
runs-on: ubuntu-latest
permissions:
issues: write
outputs:
eligible: ${{ steps.gate.outputs.eligible }}
steps:
- name: Evaluate conditions
id: gate
uses: actions/github-script@v9
with:
script: |
const MIN_STARS = 100;
const MIN_AGE_DAYS = 14;
const { owner, repo } = context.repo;
const issue = context.payload.issue;
const issue_number = issue.number;
const submitter = issue.user.login;
// Strips workflow-command syntax; every logged value goes through this.
const safe = (s) => String(s).replace(/:/g, '.').replace(/[\r\n]+/g, ' ').slice(0, 200);
const pass = (why) => { core.info('ELIGIBLE: ' + safe(why)); core.setOutput('eligible', 'true'); };
const fail = async (why) => {
core.info('INELIGIBLE: ' + safe(why));
core.setOutput('eligible', 'false');
await github.rest.issues.createComment({
owner, repo, issue_number,
body: 'This resource does not currently satisfy the required conditions '
+ 'stated in the CONTRIBUTING guidelines. Please review the '
+ '[CONTRIBUTING.md](https://github.com/' + owner + '/' + repo
+ '/blob/main/CONTRIBUTING.md) before submitting another recommendation.',
});
try {
await github.rest.issues.addLabels({
owner, repo, issue_number, labels: ['auto-closed'],
});
} catch (e) { core.info(`label add failed (non-fatal): ${safe(e.message)}`); }
await github.rest.issues.update({
owner, repo, issue_number, state: 'closed', state_reason: 'not_planned',
});
};
// ---- Condition: one open resource submission per author -----------
try {
const others = await github.paginate(github.rest.issues.listForRepo, {
owner, repo, state: 'open', labels: 'resource-submission',
creator: submitter, per_page: 100,
});
const open = others.filter(i => i.number !== issue_number && !i.pull_request);
if (open.length > 0) {
return await fail(`author has open submission(s): #${open.map(i => i.number).join(', #')}`);
}
} catch (e) {
return pass(`could not check author's open submissions (${safe(e.message)})`);
}
// Must target the Link section: author_link is also a github.com URL.
const body = issue.body || '';
const m = body.match(/###\s*Link\s*\r?\n+\s*(\S+)/i);
if (!m) return pass('no Link field found — leaving to form validation');
// Repo names legally include '_' and '.' (claude-code.nvim, some_tool).
const OWNER = '[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?';
const REPO = '[A-Za-z0-9._-]{1,100}';
const repoMatch = m[1].match(
new RegExp(`^https?://(?:www\\.)?github\\.com/(${OWNER})/(${REPO})(?:[/#?].*)?$`, 'i')
);
if (!repoMatch) return pass(`not a GitHub repo URL (${safe(m[1])}) — age/star gate does not apply`);
const tOwner = repoMatch[1];
// '|| repoMatch[2]' keeps a repo actually named "foo.git" intact.
const tRepo = repoMatch[2].replace(/\.git$/i, '') || repoMatch[2];
if (tRepo === '.' || tRepo === '..') {
return pass(`not a GitHub repo URL (${safe(m[1])}) — age/star gate does not apply`);
}
// ---- Condition (i): stars ------------------------------------------
let target;
try {
target = (await github.rest.repos.get({ owner: tOwner, repo: tRepo })).data;
} catch (e) {
return pass(`could not fetch ${tOwner}/${tRepo} (${safe(e.message)})`);
}
if (target.stargazers_count >= MIN_STARS) {
return pass(`${target.stargazers_count} stars >= ${MIN_STARS}`);
}
// Not repo.created_at: history pushed to a fresh repo predates it.
let firstCommitDate;
try {
const branch = target.default_branch;
const first = await github.rest.repos.listCommits({
owner: tOwner, repo: tRepo, sha: branch, per_page: 1,
});
if (!first.data.length) return pass('empty repository — no commits to date');
// per_page=1 makes page number == commit index, so rel="last" is oldest.
const link = first.headers.link || '';
const last = link.match(/[?&]page=(\d+)>;\s*rel="last"/);
if (last) {
const oldest = await github.rest.repos.listCommits({
owner: tOwner, repo: tRepo, sha: branch, per_page: 1, page: Number(last[1]),
});
firstCommitDate = oldest.data[0].commit.committer.date;
} else {
firstCommitDate = first.data[0].commit.committer.date;
}
} catch (e) {
return pass(`could not read commit history (${safe(e.message)})`);
}
const ageDays = (Date.now() - new Date(firstCommitDate).getTime()) / 86400000;
if (!Number.isFinite(ageDays)) {
return pass(`unparseable first-commit date (${firstCommitDate})`);
}
core.info(`first commit ${firstCommitDate} => ${ageDays.toFixed(1)} days, ${target.stargazers_count} stars`);
if (ageDays >= MIN_AGE_DAYS) {
return pass(`${ageDays.toFixed(1)} days >= ${MIN_AGE_DAYS}`);
}
await fail(`${ageDays.toFixed(1)}d < ${MIN_AGE_DAYS}d and ${target.stargazers_count} < ${MIN_STARS} stars`);
validate-resource:
name: Validate Resource Submission
needs: [eligibility]
# 'edited' skips the gate so a maintainer can reopen and iterate.
if: |
always()
&& contains(github.event.issue.labels.*.name, 'resource-submission')
&& (github.event.action == 'edited' || needs.eligibility.outputs.eligible == 'true')
runs-on: ubuntu-latest
permissions:
issues: write
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v7
with:
sparse-checkout: |
resources/
config.yaml
THE_RESOURCES_TABLE_NEW.csv
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: '3.12'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install pyyaml
- name: Parse and validate submission
env:
ISSUE_BODY: ${{ github.event.issue.body }}
PYTHONPATH: ${{ github.workspace }}
run: |
python -m resources.parse_issue_form --validate 2>&1 | tail -n 1 > validation_result.json
echo "=== Validation Result ==="
python -m json.tool validation_result.json || cat validation_result.json
- name: Remove old validation comments
uses: actions/github-script@v9
with:
script: |
const { owner, repo } = context.repo;
const issue_number = context.issue.number;
const comments = await github.rest.issues.listComments({ owner, repo, issue_number });
for (const c of comments.data) {
if (c.user.type === 'Bot' && c.body.includes('## 🤖 Validation Results')) {
await github.rest.issues.deleteComment({ owner, repo, comment_id: c.id });
}
}
- name: Post validation results
uses: actions/github-script@v9
with:
script: |
const fs = require('fs');
const result = JSON.parse(fs.readFileSync('validation_result.json', 'utf8'));
let body = '## 🤖 Validation Results\n\n';
if (result.valid) {
body += '✅ **All validation checks passed!** Your recommendation is ready for a maintainer to review.\n\n';
body += '### Parsed data\n```json\n' + JSON.stringify(result.data, null, 2) + '\n```\n';
} else {
body += '❌ **Validation failed.** Please fix the following and edit your issue:\n\n';
for (const e of result.errors) body += `- ❗ ${e}\n`;
}
if (result.warnings && result.warnings.length) {
body += '\n### Warnings\n';
for (const w of result.warnings) body += `- ⚠️ ${w}\n`;
}
body += '\n---\n<sub>Re-runs automatically when you edit the issue.</sub>';
await github.rest.issues.createComment({ ...context.repo, issue_number: context.issue.number, body });
- name: Update issue labels
uses: actions/github-script@v9
with:
script: |
const fs = require('fs');
const result = JSON.parse(fs.readFileSync('validation_result.json', 'utf8'));
const { owner, repo } = context.repo;
const issue_number = context.issue.number;
const { data: issue } = await github.rest.issues.get({ owner, repo, issue_number });
let labels = issue.labels.map(l => l.name).filter(l =>
!['validation-passed', 'validation-failed', 'validation-pending'].includes(l));
labels.push(result.valid ? 'validation-passed' : 'validation-failed');
await github.rest.issues.setLabels({ owner, repo, issue_number, labels });
- name: Cleanup
if: always()
run: rm -f validation_result.json