diff --git a/.github/workflows/check-glama.yml b/.github/workflows/check-glama.yml new file mode 100644 index 00000000..da4c0eb9 --- /dev/null +++ b/.github/workflows/check-glama.yml @@ -0,0 +1,356 @@ +name: Check Glama Link + +on: + pull_request_target: + types: [opened, edited, synchronize, closed] + +permissions: + contents: read + pull-requests: write + issues: write + +jobs: + # Post-merge welcome comment + welcome: + if: github.event.action == 'closed' && github.event.pull_request.merged == true + runs-on: ubuntu-latest + steps: + - name: Post welcome comment + uses: actions/github-script@v7 + with: + script: | + const { owner, repo } = context.repo; + const pr_number = context.payload.pull_request.number; + const marker = ''; + + const { data: comments } = await github.rest.issues.listComments({ + owner, + repo, + issue_number: pr_number, + per_page: 100, + }); + + if (!comments.some(c => c.body.includes(marker))) { + await github.rest.issues.createComment({ + owner, + repo, + issue_number: pr_number, + body: `${marker}\nThank you for your contribution! Your server has been merged. + + Are you in the MCP [Discord](https://glama.ai/mcp/discord)? Let me know your Discord username and I will give you a **server-author** flair. + + Also, make sure to claim your server on https://glama.ai/mcp/servers (click "Add Server" if it is not already there). Then update Dockerfile settings — this will make it available for anyone to use. + + If you already have a remote server, you can list it under https://glama.ai/mcp/connectors` + }); + } + + # Validation checks (only on open PRs) + check-submission: + if: github.event.action != 'closed' + runs-on: ubuntu-latest + steps: + - name: Checkout base branch + uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.base.ref }} + + - name: Validate PR submission + uses: actions/github-script@v7 + with: + script: | + const fs = require('fs'); + const { owner, repo } = context.repo; + const pr_number = context.payload.pull_request.number; + + // Read existing README to check for duplicates + const readme = fs.readFileSync('README.md', 'utf8'); + const existingUrls = new Set(); + const urlRegex = /\(https:\/\/github\.com\/[^)]+\)/gi; + for (const match of readme.matchAll(urlRegex)) { + existingUrls.add(match[0].toLowerCase()); + } + + // Get the PR diff + const { data: files } = await github.rest.pulls.listFiles({ + owner, + repo, + pull_number: pr_number, + per_page: 100, + }); + + // Permitted emojis + const permittedEmojis = [ + '\u{1F396}\uFE0F', // 🎖️ official + '\u{1F40D}', // 🐍 Python + '\u{1F4C7}', // 📇 TypeScript/JS + '\u{1F3CE}\uFE0F', // 🏎️ Go + '\u{1F980}', // 🦀 Rust + '#\uFE0F\u20E3', // #️⃣ C# + '\u2615', // ☕ Java + '\u{1F30A}', // 🌊 C/C++ + '\u{1F48E}', // 💎 Ruby + '\u2601\uFE0F', // ☁️ Cloud + '\u{1F3E0}', // 🏠 Local + '\u{1F4DF}', // 📟 Embedded + '\u{1F34E}', // 🍎 macOS + '\u{1FAA9}', // 🪟 Windows + '\u{1F427}', // 🐧 Linux + ]; + + // Only check added lines (starting with +) for glama link + const hasGlama = files.some(file => + file.patch && file.patch.split('\n') + .filter(line => line.startsWith('+')) + .some(line => line.includes('glama.ai/mcp/servers')) + ); + + // Check added lines for emoji usage + const addedLines = files + .filter(f => f.patch) + .flatMap(f => f.patch.split('\n').filter(line => line.startsWith('+'))) + .filter(line => line.includes('](https://github.com/')); + + let hasValidEmoji = false; + let hasInvalidEmoji = false; + const invalidLines = []; + const badNameLines = []; + const duplicateUrls = []; + const nonGithubUrls = []; + + // Check for non-GitHub URLs in added entry lines (list items with markdown links) + const allAddedEntryLines = files + .filter(f => f.patch) + .flatMap(f => f.patch.split('\n').filter(line => line.startsWith('+'))) + .map(line => line.replace(/^\+/, '')) + .filter(line => /^\s*-\s*\[/.test(line)); + + for (const line of allAddedEntryLines) { + // Extract the primary link URL (first markdown link) + const linkMatch = line.match(/\]\((https?:\/\/[^)]+)\)/); + if (linkMatch) { + const url = linkMatch[1]; + if (!url.startsWith('https://github.com/')) { + nonGithubUrls.push(url); + } + } + } + + // Check for duplicates + for (const line of addedLines) { + const ghMatch = line.match(/\(https:\/\/github\.com\/[^)]+\)/i); + if (ghMatch && existingUrls.has(ghMatch[0].toLowerCase())) { + duplicateUrls.push(ghMatch[0].replace(/[()]/g, '')); + } + } + + for (const line of addedLines) { + const usedPermitted = permittedEmojis.filter(e => line.includes(e)); + if (usedPermitted.length > 0) { + hasValidEmoji = true; + } else { + // Line with a GitHub link but no permitted emoji + invalidLines.push(line.replace(/^\+/, '').trim()); + } + + // Check for emojis that aren't in the permitted list + const emojiRegex = /\p{Emoji_Presentation}|\p{Emoji}\uFE0F/gu; + const allEmojis = line.match(emojiRegex) || []; + const unknownEmojis = allEmojis.filter(e => !permittedEmojis.some(p => p.includes(e) || e.includes(p))); + if (unknownEmojis.length > 0) { + hasInvalidEmoji = true; + } + + // Check that the link text uses full owner/repo format + // Pattern: [link-text](https://github.com/owner/repo) + const entryRegex = /\[([^\]]+)\]\(https:\/\/github\.com\/([^/]+)\/([^/)]+)\)/; + const match = line.match(entryRegex); + if (match) { + const linkText = match[1]; + const expectedName = `${match[2]}/${match[3]}`; + // Link text should contain owner/repo (case-insensitive) + if (!linkText.toLowerCase().includes('/')) { + badNameLines.push({ linkText, expectedName }); + } + } + } + + const emojiOk = addedLines.length === 0 || (hasValidEmoji && !hasInvalidEmoji && invalidLines.length === 0); + const nameOk = badNameLines.length === 0; + const noDuplicates = duplicateUrls.length === 0; + const allGithub = nonGithubUrls.length === 0; + + // Apply glama labels + const glamaLabel = hasGlama ? 'has-glama' : 'missing-glama'; + const glamaLabelRemove = hasGlama ? 'missing-glama' : 'has-glama'; + + // Apply emoji labels + const emojiLabel = emojiOk ? 'has-emoji' : 'missing-emoji'; + const emojiLabelRemove = emojiOk ? 'missing-emoji' : 'has-emoji'; + + // Apply name labels + const nameLabel = nameOk ? 'valid-name' : 'invalid-name'; + const nameLabelRemove = nameOk ? 'invalid-name' : 'valid-name'; + + const labelsToAdd = [glamaLabel, emojiLabel, nameLabel]; + const labelsToRemove = [glamaLabelRemove, emojiLabelRemove, nameLabelRemove]; + + if (!noDuplicates) { + labelsToAdd.push('duplicate'); + } else { + labelsToRemove.push('duplicate'); + } + + if (!allGithub) { + labelsToAdd.push('non-github-url'); + } else { + labelsToRemove.push('non-github-url'); + } + + await github.rest.issues.addLabels({ + owner, + repo, + issue_number: pr_number, + labels: labelsToAdd, + }); + + for (const label of labelsToRemove) { + try { + await github.rest.issues.removeLabel({ + owner, + repo, + issue_number: pr_number, + name: label, + }); + } catch (e) { + // Label wasn't present, ignore + } + } + + // Post comments for issues, avoiding duplicates + const { data: comments } = await github.rest.issues.listComments({ + owner, + repo, + issue_number: pr_number, + per_page: 100, + }); + + // Glama comment + if (!hasGlama) { + const marker = ''; + if (!comments.some(c => c.body.includes(marker))) { + await github.rest.issues.createComment({ + owner, + repo, + issue_number: pr_number, + body: `${marker}\nHey, + + To ensure that only working servers are listed, we're updating our listing requirements. + + Please complete the following steps: + + 1. **Ensure your server is listed on Glama.** If it isn't already, submit it at https://glama.ai/mcp/servers and verify that it passes all checks (including a successfully built Docker image and a release). + + 2. **Update your PR** by adding a \`[glama](https://glama.ai/mcp/servers/...)\` link immediately after the GitHub repository link, pointing to your server's Glama listing page. + + If you need any assistance, feel free to ask questions here or on [Discord](https://glama.ai/discord). + + P.S. If your server already has a hosted endpoint, you can also list it under https://glama.ai/mcp/connectors.` + }); + } + } + + // Emoji comment + if (!emojiOk && addedLines.length > 0) { + const marker = ''; + if (!comments.some(c => c.body.includes(marker))) { + const emojiList = [ + '🎖️ – official implementation', + '🐍 – Python', + '📇 – TypeScript / JavaScript', + '🏎️ – Go', + '🦀 – Rust', + '#️⃣ – C#', + '☕ – Java', + '🌊 – C/C++', + '💎 – Ruby', + '☁️ – Cloud Service', + '🏠 – Local Service', + '📟 – Embedded Systems', + '🍎 – macOS', + '🪟 – Windows', + '🐧 – Linux', + ].map(e => `- ${e}`).join('\n'); + + await github.rest.issues.createComment({ + owner, + repo, + issue_number: pr_number, + body: `${marker}\nYour submission is missing a required emoji tag or uses an unrecognized one. Each entry must include at least one of the permitted emojis after the repository link. + + **Permitted emojis:** + ${emojiList} + + Please update your PR to include the appropriate emoji(s). See existing entries for examples.` + }); + } + } + + // Duplicate comment + if (!noDuplicates) { + const marker = ''; + if (!comments.some(c => c.body.includes(marker))) { + const dupes = duplicateUrls.map(u => `- ${u}`).join('\n'); + await github.rest.issues.createComment({ + owner, + repo, + issue_number: pr_number, + body: `${marker}\nThe following server(s) are already listed in the repository: + + ${dupes} + + Please remove the duplicate entries from your PR.` + }); + } + } + + // Non-GitHub URL comment + if (!allGithub) { + const marker = ''; + if (!comments.some(c => c.body.includes(marker))) { + const urls = nonGithubUrls.map(u => `- ${u}`).join('\n'); + await github.rest.issues.createComment({ + owner, + repo, + issue_number: pr_number, + body: `${marker}\nWe only accept servers hosted on GitHub. The following URLs are not GitHub links: + + ${urls} + + Please update your PR to use a \`https://github.com/...\` repository link.` + }); + } + } + + // Name format comment + if (!nameOk) { + const marker = ''; + if (!comments.some(c => c.body.includes(marker))) { + const examples = badNameLines.map( + b => `- \`${b.linkText}\` should be \`${b.expectedName}\`` + ).join('\n'); + + await github.rest.issues.createComment({ + owner, + repo, + issue_number: pr_number, + body: `${marker}\nThe entry name must use the full \`owner/repo\` format (not just the repo name). + + ${examples} + + For example: \`[user/mcp-server-example](https://github.com/user/mcp-server-example)\` + + Please update your PR to use the full repository name.` + }); + } + }