[Resource]: Hedgehog - An opinionated AI software engineering workflow built for BMAD #681
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: 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 |