159 lines
6.6 KiB
YAML
159 lines
6.6 KiB
YAML
name: Label ready-to-merge skill listings
|
|
|
|
on:
|
|
pull_request_target:
|
|
types: [opened, synchronize, reopened, edited]
|
|
|
|
permissions:
|
|
contents: read
|
|
pull-requests: write
|
|
|
|
jobs:
|
|
validate:
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- name: Fetch base README
|
|
env:
|
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
BASE_REPO: ${{ github.event.pull_request.base.repo.full_name }}
|
|
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
|
run: |
|
|
gh api "repos/$BASE_REPO/contents/README.md?ref=$BASE_SHA" \
|
|
-H "Accept: application/vnd.github.raw" > base.md
|
|
|
|
- name: Fetch head README
|
|
env:
|
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }}
|
|
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
|
run: |
|
|
gh api "repos/$HEAD_REPO/contents/README.md?ref=$HEAD_SHA" \
|
|
-H "Accept: application/vnd.github.raw" > head.md
|
|
|
|
- name: List changed files
|
|
env:
|
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
REPO: ${{ github.repository }}
|
|
PR: ${{ github.event.pull_request.number }}
|
|
run: |
|
|
gh api "repos/$REPO/pulls/$PR/files" --paginate -q '.[].filename' > changed.txt
|
|
cat changed.txt
|
|
|
|
- name: Validate PR
|
|
id: validate
|
|
run: |
|
|
node <<'EOF'
|
|
const fs = require('fs');
|
|
const base = fs.readFileSync('base.md', 'utf8');
|
|
const head = fs.readFileSync('head.md', 'utf8');
|
|
const changed = fs.readFileSync('changed.txt', 'utf8')
|
|
.split('\n').map(s => s.trim()).filter(Boolean);
|
|
|
|
const fail = (msg) => { console.error('FAIL:', msg); process.exit(1); };
|
|
|
|
// 1. Only README.md changed
|
|
if (changed.length === 0) fail('no changed files reported');
|
|
for (const f of changed) {
|
|
if (f !== 'README.md') fail(`disallowed file changed: ${f}`);
|
|
}
|
|
|
|
// 2. Locate Skills <-> Getting Started bounds in both base and head
|
|
const bounds = (text) => {
|
|
const lines = text.split('\n');
|
|
let start = -1, end = -1;
|
|
for (let i = 0; i < lines.length; i++) {
|
|
if (start === -1 && /^##\s+Skills\s*$/.test(lines[i])) start = i;
|
|
else if (start !== -1 && /^##\s+Getting Started\s*$/.test(lines[i])) { end = i; break; }
|
|
}
|
|
if (start === -1 || end === -1) fail('could not locate Skills / Getting Started markers');
|
|
return { lines, start, end };
|
|
};
|
|
const b = bounds(base);
|
|
const h = bounds(head);
|
|
|
|
// 3. All edits must be within the Skills..Getting Started window.
|
|
// Compare lines outside the window — they must be identical.
|
|
const outside = (o) => [
|
|
o.lines.slice(0, o.start).join('\n'),
|
|
o.lines.slice(o.end).join('\n'),
|
|
];
|
|
const [bPre, bPost] = outside(b);
|
|
const [hPre, hPost] = outside(h);
|
|
if (bPre !== hPre) fail('changes detected before the Skills section');
|
|
if (bPost !== hPost) fail('changes detected after the Getting Started section');
|
|
|
|
// 4. Diff inside the window — find added bullet lines.
|
|
const bInside = b.lines.slice(b.start, b.end);
|
|
const hInside = h.lines.slice(h.start, h.end);
|
|
const bSet = new Set(bInside);
|
|
const addedLines = hInside.filter(l => !bSet.has(l));
|
|
|
|
const bulletRe = /^\s*-\s+\[([^\]]+)\]\(([^)]+)\)/;
|
|
const addedBullets = addedLines
|
|
.map(l => ({ line: l, m: l.match(bulletRe) }))
|
|
.filter(x => x.m)
|
|
.map(x => ({ line: x.line, name: x.m[1], url: x.m[2] }));
|
|
|
|
if (addedBullets.length === 0) fail('no new skill bullet detected in PR');
|
|
|
|
// 5. Every added bullet must link to an external repo (https, not our own host).
|
|
for (const b of addedBullets) {
|
|
if (!/^https?:\/\//i.test(b.url)) {
|
|
fail(`bullet "${b.name}" does not link to an external URL: ${b.url}`);
|
|
}
|
|
try {
|
|
const u = new URL(b.url);
|
|
const host = u.hostname.toLowerCase();
|
|
if (host.endsWith('composio.dev') || host.endsWith('anthropic.com')) {
|
|
fail(`bullet "${b.name}" links to internal host ${host}`);
|
|
}
|
|
} catch { fail(`bullet "${b.name}" has invalid URL`); }
|
|
}
|
|
|
|
// 6. No crypto/web3/blockchain/nft keywords anywhere in added lines.
|
|
const blocked = /\b(crypto|cryptocurrency|web3|blockchain|nft|defi|token(?:omics)?|wallet\b|solana|ethereum|bitcoin)\b/i;
|
|
for (const line of addedLines) {
|
|
if (blocked.test(line)) fail(`blocked keyword in added line: ${line.trim()}`);
|
|
}
|
|
|
|
// 7. Each added bullet must sit alphabetically between its immediate
|
|
// neighbors in its category (case-insensitive). Existing disorder
|
|
// elsewhere in the category is grandfathered.
|
|
const addedSet = new Set(addedBullets.map(b => b.line));
|
|
let category = null;
|
|
const groups = new Map(); // category -> [{name, added}]
|
|
for (const line of hInside) {
|
|
const hMatch = line.match(/^###\s+(.+?)\s*$/);
|
|
if (hMatch) { category = hMatch[1]; continue; }
|
|
const m = line.match(bulletRe);
|
|
if (m && category) {
|
|
if (!groups.has(category)) groups.set(category, []);
|
|
groups.get(category).push({ name: m[1], added: addedSet.has(line) });
|
|
}
|
|
}
|
|
const ci = (s) => s.toLowerCase();
|
|
for (const [cat, items] of groups) {
|
|
for (let i = 0; i < items.length; i++) {
|
|
if (!items[i].added) continue;
|
|
const prev = items[i - 1];
|
|
const next = items[i + 1];
|
|
if (prev && ci(prev.name).localeCompare(ci(items[i].name)) > 0) {
|
|
fail(`"${items[i].name}" placed after "${prev.name}" in category "${cat}" (out of alphabetical order)`);
|
|
}
|
|
if (next && ci(items[i].name).localeCompare(ci(next.name)) > 0) {
|
|
fail(`"${items[i].name}" placed before "${next.name}" in category "${cat}" (out of alphabetical order)`);
|
|
}
|
|
}
|
|
}
|
|
|
|
console.log('OK: PR meets all criteria');
|
|
EOF
|
|
|
|
- name: Add ready-to-merge label
|
|
if: success()
|
|
env:
|
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
REPO: ${{ github.repository }}
|
|
PR: ${{ github.event.pull_request.number }}
|
|
run: |
|
|
gh pr edit "$PR" --repo "$REPO" --add-label "ready-to-merge"
|