diff --git a/.github/scripts/announcement-detector.js b/.github/scripts/announcement-detector.js new file mode 100644 index 0000000000..0272477303 --- /dev/null +++ b/.github/scripts/announcement-detector.js @@ -0,0 +1,81 @@ +const github = require("./github"); +const detect = require("./detector"); +const publish = require("./reporter"); + +(async () => { + + console.log(""); + console.log("=========================================="); + console.log("Developer Announcement Recommender"); + console.log("=========================================="); + + const prNumber = github.event.pull_request.number; + + //-------------------------------------------------- + // Get PR + //-------------------------------------------------- + + const pr = await github.getPullRequest(prNumber); + + if (pr.draft) { + + console.log("Draft PR detected."); + console.log("Skipping analysis."); + + return; + + } + + //-------------------------------------------------- + // Get Files + //-------------------------------------------------- + + const files = await github.getFiles(prNumber); + + console.log(`Analyzing PR #${prNumber}`); + console.log(`Files changed: ${files.length}`); + + //-------------------------------------------------- + // Run Detector + //-------------------------------------------------- + + const result = detect(pr, files); + + console.log(""); + + console.log("Recommendation:"); + + console.log( + result.recommendation + ? "Recommend Announcement" + : "No Recommendation" + ); + + console.log(""); + + if (result.signals.length) { + + console.log("Detected Signals:"); + + result.signals.forEach(signal => { + + console.log(` - ${signal.name}`); + + }); + + } + + //-------------------------------------------------- + // Publish Comment + //-------------------------------------------------- + + await publish( + prNumber, + result + ); + + console.log(""); + console.log("Finished."); + console.log(""); + +})(); diff --git a/.github/scripts/detector.js b/.github/scripts/detector.js new file mode 100644 index 0000000000..62fc230256 --- /dev/null +++ b/.github/scripts/detector.js @@ -0,0 +1,99 @@ +const RULES = require("./rules"); + +/** + * Run all announcement rules against a PR + * + * @param {Object} pr + * @param {Array} files + * @returns {{ + * recommendation: boolean, + * signals: Array + * }} + */ +function detect(pr, files) { + + const detectedSignals = []; + + // Build searchable text once + const searchableText = [ + pr.title || "", + pr.body || "", + ...files.map(f => f.patch || "") + ].join("\n"); + + for (const rule of RULES) { + + let matched = false; + + //-------------------------------------------------- + // 1. File Status + //-------------------------------------------------- + + if (!matched && rule.detect.status) { + + matched = files.some(file => + rule.detect.status.includes(file.status) + ); + + } + + //-------------------------------------------------- + // 2. File Paths + //-------------------------------------------------- + + if (!matched && rule.detect.paths) { + + matched = files.some(file => + rule.detect.paths.some(regex => + regex.test(file.filename) + ) + ); + + } + + //-------------------------------------------------- + // 3. Keywords + //-------------------------------------------------- + + if (!matched && rule.detect.keywords) { + + matched = rule.detect.keywords.some(regex => + regex.test(searchableText) + ); + + } + + //-------------------------------------------------- + // Rule matched + //-------------------------------------------------- + + if (matched) { + + detectedSignals.push({ + id: rule.id, + name: rule.name, + description: rule.description, + priority: rule.priority + }); + + } + + } + + //-------------------------------------------------- + // Recommendation + //-------------------------------------------------- + + const recommendation = detectedSignals.length > 0; + + return { + + recommendation, + + signals: detectedSignals + + }; + +} + +module.exports = detect; diff --git a/.github/scripts/github.js b/.github/scripts/github.js new file mode 100644 index 0000000000..0451f88314 --- /dev/null +++ b/.github/scripts/github.js @@ -0,0 +1,149 @@ +const fs = require("fs"); + +const token = process.env.GITHUB_TOKEN; + +const event = JSON.parse( + fs.readFileSync(process.env.GITHUB_EVENT_PATH, "utf8") +); + +const owner = event.repository.owner.login; +const repo = event.repository.name; + +const API = `https://api.github.com/repos/${owner}/${repo}`; + +const headers = { + Authorization: `Bearer ${token}`, + Accept: "application/vnd.github+json", + "Content-Type": "application/json" +}; + +// ====================================================== +// Generic HTTP +// ====================================================== + +async function get(url) { + + const response = await fetch(url, { + headers + }); + + if (!response.ok) { + throw new Error( + `GET ${url} failed (${response.status})` + ); + } + + return response.json(); +} + +async function post(url, body) { + + const response = await fetch(url, { + method: "POST", + headers, + body: JSON.stringify(body) + }); + + if (!response.ok) { + + throw new Error( + `POST ${url} failed (${response.status})` + ); + + } + + return response.json(); + +} + +async function patch(url, body) { + + const response = await fetch(url, { + method: "PATCH", + headers, + body: JSON.stringify(body) + }); + + if (!response.ok) { + + throw new Error( + `PATCH ${url} failed (${response.status})` + ); + + } + + return response.json(); + +} + +// ====================================================== +// Pull Request +// ====================================================== + +async function getPullRequest(number) { + + return get( + `${API}/pulls/${number}` + ); + +} + +async function getFiles(number) { + + return get( + `${API}/pulls/${number}/files?per_page=100` + ); + +} + +async function getComments(number) { + + return get( + `${API}/issues/${number}/comments` + ); + +} + +async function createComment(number, body) { + + return post( + `${API}/issues/${number}/comments`, + { + body + } + ); + +} + +async function updateComment(commentId, body) { + + return patch( + `${API}/issues/comments/${commentId}`, + { + body + } + ); + +} + +// ====================================================== + +module.exports = { + + owner, + + repo, + + event, + + getPullRequest, + + getFiles, + + getComments, + + createComment, + + updateComment + +}; diff --git a/.github/scripts/reporter.js b/.github/scripts/reporter.js new file mode 100644 index 0000000000..3a43cb5321 --- /dev/null +++ b/.github/scripts/reporter.js @@ -0,0 +1,74 @@ +const github = require("./github"); + +const COMMENT_HEADER = "## 📢 Developer Announcement Recommendation"; + +/** + * Builds the markdown comment + */ +function buildComment(result) { + + if (!result.recommendation) { + + return `${COMMENT_HEADER} + +**Recommendation:** ❌ No Announcement Recommended + +No developer announcement signals were detected. + +> This PR does not appear to introduce developer-facing changes that require a community announcement. +`; + + } + + return `${COMMENT_HEADER} + +**Recommendation:** ✅ Recommend Announcement + +### Detected Signals + +${result.signals + .map(signal => `- ✅ ${signal.name}`) + .join("\n")} + +> Review this PR and, if appropriate, apply the \`Announcement\` label. +`; + +} + +/** + * Create or update analyzer comment + */ +async function publish(prNumber, result) { + + const body = buildComment(result); + + const comments = await github.getComments(prNumber); + + const existing = comments.find(comment => + comment.body && + comment.body.startsWith(COMMENT_HEADER) + ); + + if (existing) { + + console.log("Updating existing analyzer comment..."); + + await github.updateComment( + existing.id, + body + ); + + return; + + } + + console.log("Creating analyzer comment..."); + + await github.createComment( + prNumber, + body + ); + +} + +module.exports = publish; diff --git a/.github/scripts/rules.js b/.github/scripts/rules.js new file mode 100644 index 0000000000..5fedec131b --- /dev/null +++ b/.github/scripts/rules.js @@ -0,0 +1,197 @@ +module.exports = [ + + // ===================================================== + // New Documentation + // ===================================================== + + { + id: "new-doc-page", + name: "New Documentation Page", + description: "A completely new documentation page has been added.", + priority: "high", + detect: { + status: ["added"] + } + }, + + // ===================================================== + // Migration + // ===================================================== + + { + id: "migration-guide", + name: "Migration Guide", + description: "Introduces migration or upgrade guidance.", + priority: "high", + detect: { + paths: [ + /guides\/upgrade/i, + /migration/i + ], + keywords: [ + /\bmigration\b/i, + /\bupgrade\b/i + ] + } + }, + + // ===================================================== + // Breaking Changes + // ===================================================== + + { + id: "breaking-change", + name: "Breaking Changes", + description: "Documents a breaking change.", + priority: "high", + detect: { + keywords: [ + /\bbreaking\b/i, + /\bbreaking changes\b/i, + /\bbackward incompatible\b/i, + /\bBC\b/ + ] + } + }, + + // ===================================================== + // Deprecation + // ===================================================== + + { + id: "deprecation", + name: "Deprecation", + description: "Documents a deprecated feature.", + priority: "high", + detect: { + keywords: [ + /\bdeprecated\b/i, + /\bdeprecation\b/i + ] + } + }, + + // ===================================================== + // Store API + // ===================================================== + + { + id: "store-api", + name: "Store API", + description: "Introduces Store API documentation.", + priority: "high", + detect: { + paths: [ + /store-api/i + ], + keywords: [ + /GET\s+\/store-api/i, + /POST\s+\/store-api/i, + /PATCH\s+\/store-api/i, + /DELETE\s+\/store-api/i + ] + } + }, + + // ===================================================== + // Admin API + // ===================================================== + + { + id: "admin-api", + name: "Admin API", + description: "Introduces Admin API documentation.", + priority: "high", + detect: { + paths: [ + /admin-api/i + ], + keywords: [ + /GET\s+\/api/i, + /POST\s+\/api/i, + /PATCH\s+\/api/i, + /DELETE\s+\/api/i + ] + } + }, + + // ===================================================== + // Extension Points + // ===================================================== + + { + id: "extension-point", + name: "Extension Point", + description: "Introduces a new extension point.", + priority: "high", + detect: { + keywords: [ + /\bextension point\b/i, + /\bhook\b/i, + /\bevent\b/i, + /\bdecorator\b/i, + /\bsubscriber\b/i + ] + } + }, + + // ===================================================== + // SDK + // ===================================================== + + { + id: "sdk", + name: "SDK", + description: "Introduces SDK documentation.", + priority: "high", + detect: { + keywords: [ + /\bSDK\b/, + /\bMeteor\b/, + /\bApp SDK\b/i, + /\bAdmin SDK\b/i + ] + } + }, + + // ===================================================== + // Security + // ===================================================== + + { + id: "security", + name: "Security", + description: "Security related documentation.", + priority: "medium", + detect: { + keywords: [ + /\bOAuth\b/i, + /\bauthentication\b/i, + /\bauthorization\b/i, + /\bpermissions?\b/i, + /\bsecurity\b/i + ] + } + }, + + // ===================================================== + // Performance + // ===================================================== + + { + id: "performance", + name: "Performance", + description: "Performance related documentation.", + priority: "medium", + detect: { + keywords: [ + /\bperformance\b/i, + /\bcache\b/i, + /\bindexer\b/i, + /\bqueue\b/i, + /\basync\b/i + ] + } + } + +]; diff --git a/.github/workflows/announcement-detector.yml b/.github/workflows/announcement-detector.yml new file mode 100644 index 0000000000..58cac4d837 --- /dev/null +++ b/.github/workflows/announcement-detector.yml @@ -0,0 +1,29 @@ +name: Developer Announcement Recommender + +on: + pull_request: + types: + - opened + - ready_for_review + - synchronize + +permissions: + contents: read + pull-requests: write + +jobs: + analyze: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v5 + + - uses: actions/setup-node@v5 + with: + node-version: 24 + + - name: Run Announcement Detector + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: node .github/scripts/announcement-detector.js + \ No newline at end of file