diff --git a/.github/workflows/regenerate-meetings-and-videos.yml b/.github/workflows/regenerate-meetings-and-videos.yml index 34509995d063..8235ad60abd4 100644 --- a/.github/workflows/regenerate-meetings-and-videos.yml +++ b/.github/workflows/regenerate-meetings-and-videos.yml @@ -34,8 +34,13 @@ jobs: - name: Install dependencies run: npm install - - name: Regenerate - run: npm run generate:meetings && npm run generate:videos && npm run generate:dashboard + - name: Regenerate meetings and videos + run: | + npm run generate:meetings + npm run generate:videos + + - name: Regenerate dashboard + run: npm run generate:dashboard || echo "::warning::Dashboard generation failed, continuing with available data" - name: Create Pull Request with new meetings.json, newsroom-videos.json and dashboard.json version uses: peter-evans/create-pull-request@38e0b6e68b4c852a5500a94740f0e535e0d7ba54 # use 4.2.4 https://github.com/peter-evans/create-pull-request/releases/tag/v4.2.4 diff --git a/dashboard.json b/dashboard.json index 53d3a8ea0e6a..ebe83a17b0e9 100644 --- a/dashboard.json +++ b/dashboard.json @@ -1,418 +1,471 @@ { "hotDiscussions": [ { - "id": "PR_kwDOFLhIt86HHVY9", + "id": "PR_kwDOBW5R_c7Llp2_", "isPR": true, - "isAssigned": false, - "title": "chore: introduce governance board", - "author": "derberg", - "resourcePath": "/asyncapi/community/pull/1634", - "repo": "asyncapi/community", - "labels": [], - "score": 35.609649004908086 - }, - { - "id": "PR_kwDODCuNRs6NwyqP", - "isPR": true, - "isAssigned": false, - "title": "feat: initial ROS2 AsyncAPI contribution by SIEMENS AG", - "author": "gramss", - "resourcePath": "/asyncapi/bindings/pull/270", - "repo": "asyncapi/bindings", + "isAssigned": true, + "title": "feat!: dark Theme Rollout", + "author": "princerajpoot20", + "resourcePath": "/asyncapi/website/pull/5253", + "repo": "asyncapi/website", "labels": [], - "score": 33.02507770616476 + "score": 28.430284286176615 }, { - "id": "I_kwDOBW5R_c6pn_G2", + "id": "I_kwDOBW5R_c7pGlqB", "isPR": false, "isAssigned": false, - "title": "Redesign the AsyncAPI Website and Implement Dark Theme", - "author": "devilkiller-ag", - "resourcePath": "/asyncapi/website/issues/3669", + "title": "[Release] [Prod] [DarkTheme] Rollout plan for dark theme", + "author": "princerajpoot20", + "resourcePath": "/asyncapi/website/issues/5103", "repo": "asyncapi/website", "labels": [ { - "name": "gsoc", - "color": "F4D03F" + "name": "πŸ“‘ docs", + "color": "E50E99" + }, + { + "name": "Priority: P2", + "color": "FBCA04" + }, + { + "name": "triaged", + "color": "0052cc" } ], - "score": 20.38939580119737 + "score": 22.112443333692923 }, { - "id": "PR_kwDOBW5R_c6N50TQ", - "isPR": true, - "isAssigned": false, - "title": "refactor: scripts code for better code quality ", - "author": "JeelRajodiya", - "resourcePath": "/asyncapi/website/pull/3851", - "repo": "asyncapi/website", + "id": "I_kwDOFLhIt87jC_4a", + "isPR": false, + "isAssigned": true, + "title": "Code of Conduct Nomination - 2026", + "author": "Mayaleeeee", + "resourcePath": "/asyncapi/community/issues/2236", + "repo": "asyncapi/community", "labels": [], - "score": 20.102221212448114 + "score": 10.912634372471834 }, { - "id": "PR_kwDOBW5R_c6TnGCE", - "isPR": true, - "isAssigned": false, - "title": "chore(blog): add singapore summary", - "author": "iambami", - "resourcePath": "/asyncapi/website/pull/4056", - "repo": "asyncapi/website", - "labels": [], - "score": 17.230475324955524 + "id": "I_kwDOGQYLdM67ixbm", + "isPR": false, + "isAssigned": true, + "title": "Create a Unified Iconography System for Marketing & Tooling", + "author": "Mayaleeeee", + "resourcePath": "/asyncapi/brand/issues/119", + "repo": "asyncapi/brand", + "labels": [ + { + "name": ":art: design", + "color": "0D67D3" + }, + { + "name": "area/design", + "color": "0d67d3" + }, + { + "name": "bounty", + "color": "0E8A16" + } + ], + "score": 10.625459783722574 }, { - "id": "PR_kwDOBW5R_c6KgWbR", - "isPR": true, - "isAssigned": false, - "title": "feat: enabled viewing historical finance data", - "author": "SahilDahekar", - "resourcePath": "/asyncapi/website/pull/3658", + "id": "I_kwDOBW5R_c67zQdK", + "isPR": false, + "isAssigned": true, + "title": "[FEATURE] β€œEdit This Page” Button for Docs and Blog Pages", + "author": "Sauve9119", + "resourcePath": "/asyncapi/website/issues/4187", "repo": "asyncapi/website", - "labels": [], - "score": 17.230475324955524 + "labels": [ + { + "name": "enhancement", + "color": "84b6eb" + } + ], + "score": 10.338285194973315 }, { - "id": "I_kwDOFLhIt85bebeO", + "id": "I_kwDODCuNRs6NFZib", "isPR": false, "isAssigned": false, - "title": "Meeting Banners Storage", - "author": "AceTheCreator", - "resourcePath": "/asyncapi/community/issues/568", - "repo": "asyncapi/community", - "labels": [], - "score": 16.943300736206268 + "title": "ROS2 bindings", + "author": "renzo-sie", + "resourcePath": "/asyncapi/bindings/issues/254", + "repo": "asyncapi/bindings", + "labels": [ + { + "name": "enhancement", + "color": "a2eeef" + }, + { + "name": "stale", + "color": "ededed" + } + ], + "score": 10.051110606224057 + }, + { + "id": "I_kwDOCHlHJM61Y1X2", + "isPR": false, + "isAssigned": true, + "title": "explore how to handle `reply` operation in code generation", + "author": "derberg", + "resourcePath": "/asyncapi/generator/issues/1547", + "repo": "asyncapi/generator", + "labels": [ + { + "name": "enhancement", + "color": "a2eeef" + }, + { + "name": "stale", + "color": "ededed" + } + ], + "score": 8.615237662477762 }, { - "id": "PR_kwDOCHlHJM6G3FcZ", + "id": "PR_kwDOBW5R_c7OKdhx", "isPR": true, "isAssigned": false, - "title": "refactor: improve npm installation by using pacote for package resolu…", - "author": "achaljhawar", - "resourcePath": "/asyncapi/generator/pull/1329", - "repo": "asyncapi/generator", + "title": "Fix finance memory leak remove resize listener leak and layout shift", + "author": "codxbrexx", + "resourcePath": "/asyncapi/website/pull/5285", + "repo": "asyncapi/website", "labels": [], - "score": 16.943300736206268 + "score": 8.328063073728504 }, { - "id": "PR_kwDOFLhIt853IEwA", - "isPR": true, + "id": "I_kwDOBW5R_c60eZiB", + "isPR": false, "isAssigned": false, - "title": "docs: added asyncapi student ambassador md file", + "title": "[FEATURE] \"Join the AsyncAPI Community\" Section", "author": "iambami", - "resourcePath": "/asyncapi/community/pull/1333", - "repo": "asyncapi/community", - "labels": [], - "score": 16.65612614745701 + "resourcePath": "/asyncapi/website/issues/4077", + "repo": "asyncapi/website", + "labels": [ + { + "name": "Priority: P2", + "color": "FBCA04" + }, + { + "name": "triaged", + "color": "0052cc" + } + ], + "score": 7.7537138962299865 }, { - "id": "PR_kwDOFLhIt86LeC8N", + "id": "PR_kwDODwv8N866EXtA", "isPR": true, "isAssigned": false, - "title": "docs: expand community docs: contributor - simple contribution flow a…", - "author": "ezinneanne", - "resourcePath": "/asyncapi/community/pull/1730", - "repo": "asyncapi/community", + "title": "feat: add image gallery for past conferences", + "author": "kajal-jotwani", + "resourcePath": "/asyncapi/conference-website/pull/890", + "repo": "asyncapi/conference-website", "labels": [], - "score": 16.36895155870775 + "score": 7.7537138962299865 }, { - "id": "I_kwDODwv8N86un74M", + "id": "I_kwDOFLhIt874rNRL", "isPR": false, "isAssigned": true, - "title": "Add AsyncAPI Meetups Page to Conference Website", - "author": "Shriya-Chauhan", - "resourcePath": "/asyncapi/conference-website/issues/644", - "repo": "asyncapi/conference-website", + "title": "[2026 Goals][S1] Repository Audit & Consolidation (Targets O1, O2)", + "author": "princerajpoot20", + "resourcePath": "/asyncapi/community/issues/3495", + "repo": "asyncapi/community", "labels": [], - "score": 15.794602381209232 + "score": 7.179364718731469 }, { - "id": "I_kwDODou01c5ZAFWh", - "isPR": false, + "id": "PR_kwDOE8Qh387CD5_6", + "isPR": true, + "isAssigned": false, + "title": "chore: resolve broken and empty links across modelina documentation", + "author": "Ishita-190", + "resourcePath": "/asyncapi/modelina/pull/2432", + "repo": "asyncapi/modelina", + "labels": [], + "score": 7.179364718731469 + } + ], + "goodFirstIssues": [ + { + "id": "I_kwDOFttfzM8AAAABAGs3PA", + "title": "Project maintenance status + request for .NET 10 support and dependency upgrades", "isAssigned": true, - "title": "Please support File References", - "author": "philCryoport", - "resourcePath": "/asyncapi/studio/issues/528", - "repo": "asyncapi/studio", + "resourcePath": "/asyncapi/net-sdk/issues/74", + "repo": "asyncapi/net-sdk", + "author": "SergeyZubatkin", + "area": "Unknown", "labels": [ { - "name": "enhancement", + "name": "type: feature", "color": "a2eeef" }, { - "name": "keep-open", - "color": "f9dd4b" + "name": "area: core", + "color": "45EBCB" + }, + { + "name": "weight: 3", + "color": "FBCA04" + }, + { + "name": "priority: normal", + "color": "A0E245" } - ], - "score": 15.220253203710714 - } - ], - "goodFirstIssues": [ - { - "id": "I_kwDODwv8N86y8NIb", - "title": "Venue cards date misalignment", - "isAssigned": false, - "resourcePath": "/asyncapi/conference-website/issues/715", - "repo": "asyncapi/conference-website", - "author": "AceTheCreator", - "area": "Unknown", - "labels": [] - }, - { - "id": "I_kwDOBW5R_c6xUO9N", - "title": "Announcement banner not showing or misaligned on some pages", - "isAssigned": false, - "resourcePath": "/asyncapi/website/issues/4021", - "repo": "asyncapi/website", - "author": "vishvamsinh28", - "area": "Unknown", - "labels": [] + ] }, { - "id": "I_kwDOFDnrNc6uQo6r", - "title": "[Improvement]: Improve the error message while generating project fromgenerate command", + "id": "I_kwDOFttfzM7pu13X", + "title": "Make components section usable in case of code-first generation", "isAssigned": true, - "resourcePath": "/asyncapi/cli/issues/1728", - "repo": "asyncapi/cli", - "author": "AayushSaini101", - "area": "Unknown", - "labels": [] - }, - { - "id": "I_kwDOFDnrNc6tHvh5", - "title": "[BUG] AsyncAPI/generator needs to upgrade", - "isAssigned": false, - "resourcePath": "/asyncapi/cli/issues/1699", - "repo": "asyncapi/cli", - "author": "Ervishalpathak7", + "resourcePath": "/asyncapi/net-sdk/issues/72", + "repo": "asyncapi/net-sdk", + "author": "ljd1fe", "area": "Unknown", "labels": [ { - "name": "bug", - "color": "d73a4a" + "name": "type: feature", + "color": "a2eeef" }, { - "name": "gsoc", - "color": "F4D03F" + "name": "area: core", + "color": "45EBCB" + }, + { + "name": "weight: 3", + "color": "FBCA04" + }, + { + "name": "priority: normal", + "color": "A0E245" } ] }, { - "id": "I_kwDOFLhIt86sgqy5", - "title": "[πŸ“‘ Docs]: update Community Readme.md", + "id": "I_kwDOHmUTbM7m8yuY", + "title": "[BUG] Runtime crash when serializing AsyncApi without mandatory fields", "isAssigned": false, - "resourcePath": "/asyncapi/community/issues/1762", - "repo": "asyncapi/community", - "author": "thulieblack", + "resourcePath": "/asyncapi/kotlin-asyncapi/issues/228", + "repo": "asyncapi/kotlin-asyncapi", + "author": "Varadraj75", "area": "Unknown", "labels": [ { - "name": "πŸ“‘ docs", - "color": "E50E99" + "name": "bug", + "color": "d73a4a" } ] }, { - "id": "I_kwDOBW5R_c6rGtwQ", - "title": "[Docs Bug 🐞 report]: Few outdated info on /tools/generator and /tools/cli", - "isAssigned": false, - "resourcePath": "/asyncapi/website/issues/3721", - "repo": "asyncapi/website", - "author": "Adi-204", + "id": "I_kwDOFttfzM7mELUN", + "title": "V3 FluentBuilder validation failed when an operation references a component message", + "isAssigned": true, + "resourcePath": "/asyncapi/net-sdk/issues/71", + "repo": "asyncapi/net-sdk", + "author": "Foorcee", "area": "Unknown", "labels": [ { - "name": "🐞 docs bug", - "color": "FFD23F" + "name": "type: bug", + "color": "d73a4a" + }, + { + "name": "area: builders", + "color": "DCD108" + }, + { + "name": "change: fix", + "color": "E9F0B3" + }, + { + "name": "weight: 3", + "color": "FBCA04" + }, + { + "name": "priority: normal", + "color": "A0E245" } ] }, { - "id": "I_kwDOBW5R_c6qPG7g", - "title": "fix: Update code to support @octokit/request v9 and @octokit/graphql v8", + "id": "I_kwDODou01c7kIx47", + "title": "Governance issues is not properly allined.", "isAssigned": false, - "resourcePath": "/asyncapi/website/issues/3690", - "repo": "asyncapi/website", - "author": "coderabbitai", - "area": "typescript", - "labels": [] - }, - { - "id": "I_kwDOBW5R_c6ovh9-", - "title": "[FEATURE] ", - "isAssigned": false, - "resourcePath": "/asyncapi/website/issues/3649", - "repo": "asyncapi/website", - "author": "cbum-dev", - "area": "typescript", + "resourcePath": "/asyncapi/studio/issues/1255", + "repo": "asyncapi/studio", + "author": "Harsh16gupta", + "area": "Unknown", "labels": [ { - "name": "enhancement", - "color": "84b6eb" + "name": "bug", + "color": "d73a4a" + }, + { + "name": "triaged", + "color": "0052cc" } ] }, { - "id": "I_kwDOCoBobc6nflHk", - "title": "[BUG] Missing server.summary() function", + "id": "I_kwDOBW5R_c7jlJXk", + "title": "[BUG] : Unreadable Text in Roadmap Item Details", "isAssigned": false, - "resourcePath": "/asyncapi/parser-js/issues/1076", - "repo": "asyncapi/parser-js", - "author": "fmvilas", + "resourcePath": "/asyncapi/website/issues/4958", + "repo": "asyncapi/website", + "author": "sauraviiitk", "area": "Unknown", "labels": [ { "name": "bug", - "color": "d73a4a" + "color": "ee0701" + }, + { + "name": "triaged", + "color": "0052cc" } ] }, { - "id": "I_kwDOCoBobc6nSxfV", - "title": "[BUG] Missing server.title() function", + "id": "I_kwDOBW5R_c7jecmh", + "title": "[DESIGN]: Improve layout structure and text readability of TSC section", "isAssigned": false, - "resourcePath": "/asyncapi/parser-js/issues/1075", - "repo": "asyncapi/parser-js", - "author": "fmvilas", + "resourcePath": "/asyncapi/website/issues/4954", + "repo": "asyncapi/website", + "author": "SinghSwayam", "area": "Unknown", "labels": [ { - "name": "bug", - "color": "d73a4a" + "name": "triaged", + "color": "0052cc" } ] }, { - "id": "I_kwDOFLhIt86m3c8R", - "title": "[BUG] Slack groups are not updated", + "id": "I_kwDOBW5R_c7ih5i8", + "title": "[DESIGN]: Footer Netlify badge alt text renders poorly and inconsistently when image fails to load", "isAssigned": false, - "resourcePath": "/asyncapi/community/issues/1658", - "repo": "asyncapi/community", - "author": "fmvilas", + "resourcePath": "/asyncapi/website/issues/4921", + "repo": "asyncapi/website", + "author": "SHUBHANSHU602", "area": "Unknown", "labels": [ { - "name": "bug", - "color": "d73a4a" + "name": "triaged", + "color": "0052cc" } ] }, { - "id": "I_kwDOBW5R_c6mv2HD", - "title": "[BUG] : Comminutiy newsroom latest news section is not ui friendly.", + "id": "I_kwDOBW5R_c7hqpc-", + "title": "refactor: Remove or use unused exported filterIssues function", "isAssigned": false, - "resourcePath": "/asyncapi/website/issues/3587", + "resourcePath": "/asyncapi/website/issues/4889", "repo": "asyncapi/website", - "author": "jayraj175coder", + "author": "sammy200-ui", "area": "Unknown", "labels": [ { - "name": "bug", - "color": "ee0701" + "name": "triaged", + "color": "0052cc" } ] }, { - "id": "I_kwDOFDnrNc6mGxDI", - "title": "[BUG] Inaccurate error in `asyncapi optimize`", + "id": "I_kwDOBW5R_c7hMwbK", + "title": "Missing hover effect on two-color (Purple and Dark/Black) buttons in Docs options pages", "isAssigned": false, - "resourcePath": "/asyncapi/cli/issues/1626", - "repo": "asyncapi/cli", - "author": "derberg", + "resourcePath": "/asyncapi/website/issues/4867", + "repo": "asyncapi/website", + "author": "Gorachand-Senapati", "area": "Unknown", "labels": [ { "name": "bug", - "color": "d73a4a" + "color": "ee0701" + }, + { + "name": "triaged", + "color": "0052cc" } ] }, { - "id": "I_kwDODwv8N86f37o-", - "title": "Return to top arrow feature/button", + "id": "I_kwDOBW5R_c7gRD8m", + "title": "Refactor(navbar): Use twMerge for z-index className management instead of template literal", "isAssigned": false, - "resourcePath": "/asyncapi/conference-website/issues/475", - "repo": "asyncapi/conference-website", - "author": "thulieblack", + "resourcePath": "/asyncapi/website/issues/4779", + "repo": "asyncapi/website", + "author": "Sam-61s", "area": "Unknown", "labels": [ { "name": "enhancement", - "color": "a2eeef" + "color": "84b6eb" } ] }, { - "id": "I_kwDOBW5R_c6csZFy", - "title": "Create Custom LogService Using Winston for Error Logging", - "isAssigned": false, - "resourcePath": "/asyncapi/website/issues/3358", - "repo": "asyncapi/website", - "author": "coderabbitai", - "area": "typescript", - "labels": [] - }, - { - "id": "I_kwDOCVQpZM6YZd4E", - "title": "A fragment with only one child is redundant.", - "isAssigned": false, - "resourcePath": "/asyncapi/asyncapi-react/issues/1054", - "repo": "asyncapi/asyncapi-react", - "author": "AceTheCreator", + "id": "I_kwDOFDnrNc7T8y98", + "title": "Fix Sonar Cloud issues in the CLI Project", + "isAssigned": true, + "resourcePath": "/asyncapi/cli/issues/1881", + "repo": "asyncapi/cli", + "author": "AayushSaini101", "area": "Unknown", "labels": [ { - "name": "Hacktoberfest", - "color": "FF8AE2" + "name": "stale", + "color": "ededed" } ] }, { - "id": "I_kwDOCVQpZM6YZbiE", - "title": "Remove this redundant \"undefined\"", + "id": "I_kwDOFDnrNc7DhEVD", + "title": "Enable `--compile` for `generate fromTemplate` command", "isAssigned": false, - "resourcePath": "/asyncapi/asyncapi-react/issues/1052", - "repo": "asyncapi/asyncapi-react", - "author": "AceTheCreator", - "area": "Unknown", + "resourcePath": "/asyncapi/cli/issues/1829", + "repo": "asyncapi/cli", + "author": "derberg", + "area": "javascript", "labels": [ { "name": "stale", "color": "ededed" - }, - { - "name": "Hacktoberfest", - "color": "FF8AE2" } ] }, { - "id": "I_kwDOE8Qh386HJeIz", - "title": "[BUG] Implement avro schema data type ", - "isAssigned": false, - "resourcePath": "/asyncapi/modelina/issues/1974", - "repo": "asyncapi/modelina", - "author": "akkshitgupta", - "area": "typescript", + "id": "I_kwDOFDnrNc6uQo6r", + "title": "[Improvement]: Improve the error message while generating project fromgenerate command", + "isAssigned": true, + "resourcePath": "/asyncapi/cli/issues/1728", + "repo": "asyncapi/cli", + "author": "AayushSaini101", + "area": "Unknown", "labels": [ { - "name": "avro", - "color": "EC3D91" + "name": "stale", + "color": "ededed" } ] }, { - "id": "I_kwDOCHlHJM58YMi8", - "title": "Improve arborist (npm installation) to have no hacks", + "id": "I_kwDOBW5R_c6ovh9-", + "title": "[FEATURE] ", "isAssigned": false, - "resourcePath": "/asyncapi/generator/issues/1102", - "repo": "asyncapi/generator", - "author": "derberg", - "area": "javascript", + "resourcePath": "/asyncapi/website/issues/3649", + "repo": "asyncapi/website", + "author": "cbum-dev", + "area": "typescript", "labels": [ { "name": "enhancement", - "color": "a2eeef" + "color": "84b6eb" } ] }, @@ -435,29 +488,6 @@ } ] }, - { - "id": "I_kwDOFDnrNc5xaTa3", - "title": "Parallel execution command asyncapi generate got error", - "isAssigned": true, - "resourcePath": "/asyncapi/cli/issues/814", - "repo": "asyncapi/cli", - "author": "Zacama", - "area": "Unknown", - "labels": [ - { - "name": "bug", - "color": "d73a4a" - }, - { - "name": "bounty", - "color": "0e8a16" - }, - { - "name": "level/advanced", - "color": "0e8a16" - } - ] - }, { "id": "I_kwDODtTERs5wDOAH", "title": "Support decimal min and max", @@ -470,6 +500,10 @@ { "name": "bug", "color": "d73a4a" + }, + { + "name": "stale", + "color": "ededed" } ] }, @@ -492,40 +526,6 @@ } ] }, - { - "id": "I_kwDOFi_gUM5rHafV", - "title": "Glee crashes when body is not proper json in http request", - "isAssigned": false, - "resourcePath": "/asyncapi/glee/issues/476", - "repo": "asyncapi/glee", - "author": "KhudaDad414", - "area": "typescript", - "labels": [ - { - "name": "bug", - "color": "d73a4a" - }, - { - "name": "stale", - "color": "ededed" - } - ] - }, - { - "id": "I_kwDOBW5R_c5eFaBF", - "title": "Add proper dropdowns to the Filters Select Menu", - "isAssigned": true, - "resourcePath": "/asyncapi/website/issues/1318", - "repo": "asyncapi/website", - "author": "akshatnema", - "area": "Unknown", - "labels": [ - { - "name": "enhancement", - "color": "84b6eb" - } - ] - }, { "id": "I_kwDOIUldZc5XXsGH", "title": "Filter out application which are not in use", @@ -545,44 +545,6 @@ } ] }, - { - "id": "I_kwDOE8Qh385WoDaU", - "title": "Add attrs and/or dataclasses presets to Python generator", - "isAssigned": false, - "resourcePath": "/asyncapi/modelina/issues/999", - "repo": "asyncapi/modelina", - "author": "tokarenko", - "area": "typescript", - "labels": [ - { - "name": "enhancement", - "color": "a2eeef" - }, - { - "name": "stale", - "color": "ededed" - }, - { - "name": "Python generator", - "color": "5319e7" - } - ] - }, - { - "id": "I_kwDODyzcIc5Uso2Q", - "title": "Add support for minimum amount of approvals before merge", - "isAssigned": false, - "resourcePath": "/asyncapi/.github/issues/190", - "repo": "asyncapi/.github", - "author": "fmvilas", - "area": "ci-cd", - "labels": [ - { - "name": "enhancement", - "color": "a2eeef" - } - ] - }, { "id": "I_kwDOFiHaLM5DeQ4y", "title": "Add support for HTML output", @@ -595,6 +557,10 @@ { "name": "enhancement", "color": "a2eeef" + }, + { + "name": "stale", + "color": "ededed" } ] } diff --git a/docs/DASHBOARD_GITHUB_DATA_COLLECTION.md b/docs/DASHBOARD_GITHUB_DATA_COLLECTION.md new file mode 100644 index 000000000000..bf735ed77c37 --- /dev/null +++ b/docs/DASHBOARD_GITHUB_DATA_COLLECTION.md @@ -0,0 +1,348 @@ +# Dashboard GitHub Data Collection: How It Works + +This document explains in detail how the community dashboard data collection script works, what was causing it to fail, what was changed to fix it, and what safeguards are in place to keep it reliable. It is intended as a reference for anyone who needs to maintain, debug, or tune this system in the future. + +--- + +## Table of Contents + +1. [Terminology](#terminology) +2. [What Does the Dashboard Script Do?](#what-does-the-dashboard-script-do) +3. [How GitHub GraphQL Pagination Works](#how-github-graphql-pagination-works) +4. [Understanding Query Complexity and the 502 Problem](#understanding-query-complexity-and-the-502-problem) +5. [What Was Wrong Before (Root Cause)](#what-was-wrong-before-root-cause) +6. [What Changed (The Fix)](#what-changed-the-fix) +7. [Business Logic: Unchanged](#business-logic-unchanged) +8. [Retry and Fallback Mechanisms](#retry-and-fallback-mechanisms) +9. [Tuning Guide](#tuning-guide) +10. [Configuration Reference](#configuration-reference) + +--- + +## Terminology + +Before diving in, here are the key terms used throughout this document: + +### PAGE_SIZE (`first` parameter) + +When you search for issues on GitHub's GraphQL API, you cannot get all results at once. You get them in "pages". `PAGE_SIZE` (the `first` parameter in the query) controls **how many items you get back per request**. + +**Example:** If there are 90 open issues matching your search and `PAGE_SIZE = 30`, you need 3 requests (pages) to get all of them: page 1 returns items 1-30, page 2 returns items 31-60, page 3 returns items 61-90. + +### Comments limit (`comments(first: N)`) + +Each issue or PR can have comments. The `comments(first: 20)` part of the query means: **for each issue in this page, also fetch up to 20 of its comments**. This is NOT a separate API call -- it is embedded inside the same request. GitHub fetches the issue data AND its comments in one go. + +If an issue has 50 comments and we asked for `comments(first: 20)`, we get only the first 20. The response will include `hasNextPage: true` to tell us there are more. Our script can then optionally make a separate follow-up call to get the full comment data for that specific issue. + +### Nodes + +In GitHub's GraphQL API, a "node" is any individual data object returned: an issue, a comment, a label, a reaction, etc. When GitHub processes your request, it has to look up and assemble every node you asked for. The more nodes, the more work the server does. + +**The key insight**: nodes multiply across nesting levels. If you ask for 30 issues and each issue has up to 20 comments, that is potentially 30 x 20 = 600 comment nodes, plus 30 issue nodes, plus all their nested reactions and labels. The total node count determines how long GitHub's server takes to respond. + +### Pagination cursor (`after` / `endCursor`) + +When a search has more results than fit in one page, GitHub returns a cursor (a string like `"Y3Vyc29yOjUw"`) along with the results. To get the next page, you pass this cursor in the `after` parameter of your next request. Think of it like a bookmark: "give me results starting after this point." + +### Rate limits + +GitHub has two separate rate limit systems: + +1. **Primary rate limit (point-based)**: Each authenticated user gets 5,000 points per hour. Each GraphQL request costs a certain number of points based on its complexity. A simple query might cost 1 point, a complex search might cost 10-30 points. You can see the cost and remaining points in each response. + +2. **Secondary rate limit (velocity-based)**: Even if you have plenty of points, GitHub limits how fast you can make requests. If you send too many requests in a short time (roughly 90+ per minute), GitHub blocks you with a "secondary rate limit" error. This is abuse prevention. + +### 502 Bad Gateway / "Unicorn" page + +When a single GraphQL request asks for too much data and GitHub's server cannot compute the response within its internal time limit (roughly 10 seconds), it returns a 502 Bad Gateway error or a "Unicorn" error page. This is NOT a rate limit -- it means the individual query was too expensive to process. + +--- + +## What Does the Dashboard Script Do? + +The script (`scripts/dashboard/build-dashboard.ts`) collects two types of data for the [community dashboard page](https://www.asyncapi.com/community/dashboard): + +### 1. Hot Discussions (top 12) + +Searches for all **open issues** and **open pull requests** across the entire `asyncapi` GitHub organization that were updated within the last 6 months. For each item, it calculates an "interaction score" based on: + +- Number of reactions on the issue/PR +- Number of comments +- Number of reactions on individual comments +- For PRs: number of reviews and review comments + +The score is then divided by the age of the last update (older = lower score). The top 12 items by score are selected. Items authored by `asyncapi-bot` are excluded. + +### 2. Good First Issues (all of them) + +Searches for all open issues with the `good first issue` label across the `asyncapi` org. All matching issues are included (no scoring or filtering). + +### Output + +Both datasets are written to `dashboard.json` at the repo root, which is consumed by the Next.js dashboard page. + +--- + +## How GitHub GraphQL Pagination Works + +Here is a concrete example of how pagination works with `PAGE_SIZE = 30`: + +### Request 1 + +``` +"Give me the first 30 open issues in the asyncapi org" +``` + +GitHub returns: +- 30 issues (with their comments, labels, reactions, etc.) +- `hasNextPage: true` (there are more results) +- `endCursor: "Y3Vyc29yOjMw"` (bookmark for where to continue) + +### Request 2 + +``` +"Give me the next 30 issues, starting after cursor Y3Vyc29yOjMw" +``` + +GitHub returns: +- 30 more issues +- `hasNextPage: true` +- `endCursor: "Y3Vyc29yOjYw"` + +### Request 3 + +``` +"Give me the next 30, starting after cursor Y3Vyc29yOjYw" +``` + +GitHub returns: +- 15 issues (these are the last ones) +- `hasNextPage: false` (no more pages) + +**Total**: 3 requests to get 75 issues. With `PAGE_SIZE = 20`, this would have been 4 requests. With `PAGE_SIZE = 10`, it would be 8 requests. Larger page sizes mean fewer requests -- but each request is heavier. + +--- + +## Understanding Query Complexity and the 502 Problem + +This is the most important section to understand why certain page sizes fail. + +### What our query actually asks for + +Here is a simplified view of what one search request asks GitHub to compute: + +``` +search(first: 30) -- Give me 30 issues + for EACH of those 30 issues: + β”œβ”€β”€ assignees(first: 1) -- 30 x 1 = 30 assignee checks + β”œβ”€β”€ timelineItems(first: 1) -- 30 x 1 = 30 timeline lookups + β”œβ”€β”€ author -- 30 x 1 = 30 author lookups + β”œβ”€β”€ labels(first: 10) -- 30 x 10 = 300 label nodes + β”œβ”€β”€ reactions(last: 1) -- 30 x 1 = 30 reaction nodes + └── comments(first: 20) -- 30 x 20 = 600 comment nodes + for EACH of those comments: + └── reactions(last: 1) -- 600 x 1 = 600 reaction nodes +``` + +**Total potential nodes**: ~1,620 + +### What happens when we increase the numbers + +| PAGE_SIZE | comments per item | Approximate total nodes | Result | +|-----------|-------------------|-------------------------|--------| +| 20 | 20 | ~1,040 | Works reliably (6 seconds) | +| 30 | 20 | ~1,560 | Works reliably (8 seconds) | +| 50 | 20 | ~2,600 | Works reliably (8 seconds) | +| 50 | 50 | ~5,600 | Unreliable (sometimes 502) | +| 100 | 50 | ~11,200 | Always 502 | +| 100 | 100 | ~21,200 | Always 502 | + +**Why 502?** GitHub's GraphQL server has an internal computation timeout (roughly 10 seconds). When the total work exceeds this, the server gives up and returns a 502 Bad Gateway or a "Unicorn" error page. This is a server-side limit that we cannot change -- we can only control how much work we ask for in each request. + +**Important**: The `first: 100` limit on each field means GitHub allows up to 100 items per connection field. But that does not mean using 100 everywhere will work. The total combined complexity across all nested fields is what matters. + +### Why comments(first: 20) specifically? + +`comments(first: 20)` means: for each issue in the page, fetch its first 20 comments. This is used to calculate the interaction score (summing reactions on those comments). + +If an issue has more than 20 comments, the response says `hasNextPage: true`. The script then makes a **separate, lightweight** follow-up call for just that one issue to get its full comment data. This follow-up call is cheap because it fetches only 1 item, not 30. + +This is the same value the original script used. We chose to keep it at 20 because: +- It keeps the bulk search queries lightweight and reliable +- Issues with >20 comments are relatively rare +- The few that need follow-up are handled by individual, cheap API calls + +--- + +## What Was Wrong Before (Root Cause) + +The original script hit GitHub's **secondary rate limit** (velocity-based) because it made too many API requests too quickly: + +1. **No time filter**: Queries fetched ALL open issues and PRs across the entire org, regardless of when they were last updated. With 500-1000+ open issues and 200+ open PRs, this required massive pagination. + +2. **Page size of 20**: With so many items and a small page size, the script needed 50-80+ paginated requests just for the initial data fetch. + +3. **Only 500ms delay between requests**: At 500ms per request, 80 requests complete in 40 seconds -- well above the ~90 requests/minute threshold that triggers the secondary rate limit. + +4. **No retry**: When the rate limit error came back, the script threw an error immediately instead of waiting and retrying. + +5. **Silent failure**: The `start()` function caught the error, logged it, and exited with code 0. The workflow proceeded to create a PR, but `dashboard.json` was never updated. This is why the dashboard showed year-old data while meetings and newsroom videos kept updating. + +--- + +## What Changed (The Fix) + +### 1. Time filter on hot discussions queries + +Added `updated:>YYYY-MM-DD` to the search queries for hot discussions (issues and PRs). Only items updated within the last 6 months are fetched. This dramatically reduces the result set from 1000+ items to a few hundred. + +**Why this is safe**: The scoring formula heavily penalizes old items. An issue not updated in 6 months gets a near-zero score and would never appear in the top 12 regardless. The filter simply avoids fetching data we would discard. + +The `good first issues` query has NO time filter -- all of them are shown on the dashboard regardless of age. + +### 2. Increased page size from 20 to 30 + +Fewer pages = fewer requests = less likely to hit velocity-based rate limits. The jump from 20 to 30 reduces total pagination requests by ~33%. + +We chose 30 (not 50 or 100) because testing showed it works reliably within GitHub's server-side complexity limits when combined with the nested fields we request. + +### 3a. Sort hot discussions by most recently updated (`sort:updated-desc`) + +Both hot discussion search queries now include `sort:updated-desc`. This ensures GitHub returns the most recently active issues and PRs first, rather than using its internal relevance ordering. + +**Why this matters**: Without this sort, GitHub's default ordering can place highly-active items far past the 150-item cap (5 pages Γ— 30 items). A PR last updated yesterday could appear at position 160 in default order and never be fetched or scored. With `sort:updated-desc`, recently active items are always in the first pages. + +**Cost**: Zero additional API calls. It is a single string addition to the query. + +The `goodFirstIssues` query intentionally does **not** use this sort. The purpose of showing good first issues is to distribute new contributor traffic across all available issues β€” including quiet ones that have received little attention. Sorting by most recently updated would keep sending contributors to the same already-active issues while neglecting ones that genuinely need someone to pick them up. + +### 3. Retry with exponential backoff + +If a request fails with a retryable error (secondary rate limit, 502, network timeout), the script now waits and retries up to 3 times with increasing delays: 60 seconds, then 120 seconds, then 240 seconds. + +### 4. Adaptive throttling + +After each successful request, the script checks how many rate limit points are remaining and adjusts the delay: +- More than 500 remaining: 2-second delay (normal) +- 100-500 remaining: 5-second delay (cautious) +- Below 100: wait until the rate limit resets (could be up to an hour) + +### 5. Maximum pages cap + +Hot discussion queries are capped at 5 pages (150 items with page size 30). Since we only need the top 12 by score, 150 most-recently-updated items is more than sufficient (the `sort:updated-desc` ordering ensures these are the right 150 items). Good first issues are also capped at 5 pages (150 items) but use no sort filter β€” the goal there is to surface all available issues without bias. + +### 6. Progressive data saving + +The script now fetches hot discussions and good first issues independently. If one fails, the other still runs. Whatever data was successfully collected is written to `dashboard.json`. Partial fresh data is better than completely stale data. + +### 7. Workflow separation + +In the GitHub Actions workflow, the dashboard generation step is now separate from meetings/videos and uses a fallback (`|| echo "warning"`), so dashboard failures do not prevent meetings and videos from being updated. + +--- + +## Business Logic: Unchanged + +**The scoring and ranking logic is completely identical to before.** Specifically: + +- **Scoring formula**: `interactionsCount / (monthsSince(updatedAt) + 2) ^ 1.8` -- unchanged +- **Interaction counting**: reactions + comments + comment reactions (+ reviews for PRs) -- unchanged +- **Top 12 selection**: sort by score descending, filter out `asyncapi-bot`, take first 12 -- unchanged +- **Good first issues mapping**: same fields, same label filtering, same area extraction -- unchanged +- **Output format**: same JSON structure, same file path (`dashboard.json`) -- unchanged + +The only changes are in **how data is fetched** (fewer, slower, smarter requests with retry logic) and **how failures are handled** (retry, partial data, proper error codes). The actual data processing and output are identical. + +--- + +## Retry and Fallback Mechanisms + +Here is the complete chain of safeguards, from first line of defense to last resort: + +### Layer 1: Adaptive throttling (prevents problems) + +Every request is followed by an adaptive delay (2s-5s, or wait until reset). This keeps request velocity well below the secondary rate limit threshold. + +### Layer 2: Retry with exponential backoff (handles transient errors) + +If a request fails with any of these errors, it is automatically retried: +- Secondary rate limit ("You have exceeded a secondary rate limit") +- Server errors ("502 Bad Gateway", "Unicorn") +- Network errors ("ECONNRESET", "ETIMEDOUT") + +Retry schedule: +- Attempt 1: wait 60 seconds, then retry +- Attempt 2: wait 120 seconds, then retry +- Attempt 3: wait 240 seconds, then retry +- If all 3 retries fail: give up on this section + +**If the 502 error happens with PAGE_SIZE=30, the retry will handle it.** The 502 from GitHub is often transient (server load varies). A 60-second wait usually resolves it. This was verified during testing -- the same query that fails once can succeed moments later. + +### Layer 3: Progressive data saving (handles partial failure) + +If hot discussions fail entirely (even after retries) but good first issues succeed, the script writes: +```json +{ "hotDiscussions": [], "goodFirstIssues": [...fresh data...] } +``` + +If good first issues fail but hot discussions succeed, it writes: +```json +{ "hotDiscussions": [...fresh data...], "goodFirstIssues": [] } +``` + +Partial fresh data is better than completely stale data from a year ago. + +### Layer 4: Non-blocking workflow step (handles complete failure) + +If BOTH sections fail (no data at all), the script throws an error. But in the GitHub Actions workflow, the dashboard step uses a fallback: +```yaml +npm run generate:dashboard || echo "::warning::Dashboard generation failed, continuing with available data" +``` + +This means meetings and videos are still updated and committed even if the dashboard generation fails completely. The failure is logged as a warning in the workflow output. + +### If you need to reduce PAGE_SIZE further + +If the script starts failing persistently in the future (for example, because the asyncapi org grows significantly and queries become more complex), you can reduce `PAGE_SIZE` in `build-dashboard.ts`: + +```typescript +const PAGE_SIZE = 30; // Reduce to 20 if needed +``` + +This is a safe change -- it just means more pagination requests with more delay between them. The retry mechanism will handle the extra time. The scoring logic and output are unaffected. + +--- + +## Tuning Guide + +All configuration constants are at the top of `scripts/dashboard/build-dashboard.ts`: + +| Constant | Current Value | What It Controls | +|----------|---------------|------------------| +| `HOT_DISCUSSIONS_MONTHS_BACK` | `6` | How far back to search for hot discussions. Increase to cast a wider net, decrease to reduce API load. | +| `PAGE_SIZE` | `30` | Items per API request. Higher = fewer requests but heavier queries. Keep at 30 or below β€” at 100, queries always 502. | +| `MAX_PAGES_HOT_DISCUSSIONS` | `5` | Maximum pagination pages for hot discussions. 5 pages Γ— 30 items = 150 items max. With `sort:updated-desc` these are the 150 most recently active items. | +| `MAX_PAGES_GOOD_FIRST_ISSUES` | `5` | Maximum pagination pages for good first issues. 5 pages Γ— 30 items = 150 items max. No sort filter applied β€” all issues are shown without bias toward recently active ones. | +| `BASE_DELAY_MS` | `2000` | Minimum delay between API requests (milliseconds). Increase if hitting rate limits. | +| `MAX_RETRIES` | `3` | Number of retry attempts on retryable errors. | +| `RETRY_BASE_DELAY_MS` | `60000` | Initial retry delay (60 seconds). Doubles with each attempt: 60s, 120s, 240s. | + +--- + +## Configuration Reference + +### Files involved + +| File | Role | +|------|------| +| `scripts/dashboard/build-dashboard.ts` | Main script: orchestration, pagination, retry, scoring, output | +| `scripts/dashboard/issue-queries.ts` | GraphQL query definitions | +| `dashboard.json` | Output file consumed by the dashboard page | +| `.github/workflows/regenerate-meetings-and-videos.yml` | Workflow that runs this script daily | +| `types/scripts/dashboard.ts` | TypeScript type definitions | +| `tests/dashboard/build-dashboard.test.ts` | Tests | + +### Workflow schedule + +The script runs daily at 00:10 UTC via the `regenerate-meetings-and-videos.yml` workflow. It can also be triggered manually via `workflow_dispatch`. diff --git a/scripts/dashboard/build-dashboard.ts b/scripts/dashboard/build-dashboard.ts index aa69bf989236..effdaa3a841b 100644 --- a/scripts/dashboard/build-dashboard.ts +++ b/scripts/dashboard/build-dashboard.ts @@ -7,7 +7,6 @@ import type { Discussion, GoodFirstIssues, HotDiscussionsIssuesNode, - HotDiscussionsPullRequestsNode, IssueById, MappedIssue, ProcessedDiscussion, @@ -21,6 +20,99 @@ import { Queries } from './issue-queries'; const currentFilePath = fileURLToPath(import.meta.url); const currentDirPath = dirname(currentFilePath); +const HOT_DISCUSSIONS_MONTHS_BACK = 6; + +const PAGE_SIZE = 30; + +const MAX_PAGES_HOT_DISCUSSIONS = 5; + +const MAX_PAGES_GOOD_FIRST_ISSUES = 5; + +const BASE_DELAY_MS = 2000; + +const MAX_RETRIES = 3; + +const RETRY_BASE_DELAY_MS = 60000; + +function getHotDiscussionsCutoffDate(): string { + const now = new Date(); + const targetMonth = now.getMonth() - HOT_DISCUSSIONS_MONTHS_BACK; + // Clamp the day to the last valid day of the target month so that dates like + // Aug 31 don't roll over into the next month (e.g. Feb 31 β†’ Mar 3). + const lastDayOfTargetMonth = new Date(now.getFullYear(), targetMonth + 1, 0).getDate(); + const clampedDay = Math.min(now.getDate(), lastDayOfTargetMonth); + + return new Date(now.getFullYear(), targetMonth, clampedDay).toISOString().split('T')[0]; +} + +function isRetryableError(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error); + const lowerMessage = message.toLowerCase(); + + return ( + lowerMessage.includes('secondary rate limit') || + lowerMessage.includes('502 bad gateway') || + lowerMessage.includes('unicorn') || + lowerMessage.includes('server error') || + lowerMessage.includes('econnreset') || + lowerMessage.includes('etimedout') + ); +} + +async function retryWithBackoff(fn: () => Promise, context: string): Promise { + let lastError: unknown; + + for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { + try { + // eslint-disable-next-line no-await-in-loop + return await fn(); + } catch (error) { + lastError = error; + + if (!isRetryableError(error)) { + throw error; + } + + if (attempt === MAX_RETRIES) { + break; + } + + const delayMs = RETRY_BASE_DELAY_MS * 2 ** attempt; + + logger.warn( + `Retryable error during ${context} (attempt ${attempt + 1}/${MAX_RETRIES}). ` + + `Retrying in ${delayMs / 1000}s...` + ); + // eslint-disable-next-line no-await-in-loop + await pause(delayMs); + } + } + + const originalMessage = lastError instanceof Error ? lastError.message : String(lastError); + + throw new Error(`Exhausted ${MAX_RETRIES} retries for ${context}: ${originalMessage}`); +} + +const MAX_ADAPTIVE_DELAY_MS = 15 * 60_000; + +async function adaptiveDelay(rateLimit: Discussion['rateLimit']): Promise { + if (rateLimit.remaining <= 100) { + const resetTime = new Date(rateLimit.resetAt).getTime(); + const safeResetTime = Number.isFinite(resetTime) ? resetTime : Date.now(); + const rawWaitMs = Math.max(safeResetTime - Date.now(), 0) + 1000; + const waitMs = Math.min(rawWaitMs, MAX_ADAPTIVE_DELAY_MS); + + logger.warn( + `Rate limit critically low (${rateLimit.remaining} remaining). Waiting ${Math.round(waitMs / 1000)}s until reset.` + ); + await pause(waitMs); + } else if (rateLimit.remaining <= 500) { + await pause(5000); + } else { + await pause(BASE_DELAY_MS); + } +} + /** * Calculates the number of full months elapsed since the provided date. * @@ -55,59 +147,51 @@ function getLabel(issue: GoodFirstIssues, filter: string): string | undefined { } /** - * Recursively fetches discussion nodes from the GitHub GraphQL API. - * - * This function executes a provided GraphQL query to retrieve discussion nodes in paginated batches. - * It automatically retrieves subsequent pages if available by recursively updating the pagination cursor. - * A short pause between requests is included to help manage API rate limits, and a warning is logged when the remaining limit is low. + * Fetches discussion nodes from the GitHub GraphQL API with pagination, adaptive throttling, and retry logic. * * @param query - The GraphQL query to execute. * @param pageSize - The number of discussion nodes to retrieve per page. - * @param endCursor - (Optional) The pagination cursor; set to null to start from the first page. + * @param endCursor - The pagination cursor; null to start from the first page. + * @param maxPages - Maximum number of pages to fetch. 0 means unlimited. + * @param currentPage - Current page counter (used internally for recursion). * @returns A promise that resolves with an array of discussion nodes. */ async function getDiscussions( query: string, pageSize: number, - endCursor: null | string = null + endCursor: null | string = null, + maxPages: number = 0, + currentPage: number = 1 ): Promise { const token = process.env.GITHUB_TOKEN; if (!token) { throw new Error('GitHub token is not set in environment variables'); } - try { - const result: Discussion = await graphql(query, { - first: pageSize, - after: endCursor, - headers: { - authorization: `token ${process.env.GITHUB_TOKEN}` - } - }); - if (result.rateLimit.remaining <= 100) { - logger.warn( - 'GitHub GraphQL rateLimit \n' + - `cost = ${result.rateLimit.cost}\n` + - `limit = ${result.rateLimit.limit}\n` + - `remaining = ${result.rateLimit.remaining}\n` + - `resetAt = ${result.rateLimit.resetAt}` - ); - } + const result: Discussion = await retryWithBackoff( + () => + graphql(query, { + first: pageSize, + after: endCursor, + headers: { + authorization: `token ${token}` + } + }), + `getDiscussions page ${currentPage}` + ); - await pause(500); + await adaptiveDelay(result.rateLimit); - const { hasNextPage } = result.search.pageInfo; + const { hasNextPage } = result.search.pageInfo; - if (!hasNextPage) { - return result.search.nodes; - } - - return result.search.nodes.concat(await getDiscussions(query, pageSize, result.search.pageInfo.endCursor)); - } catch (error) { - logger.error(error); - throw error; + if (!hasNextPage || (maxPages > 0 && currentPage >= maxPages)) { + return result.search.nodes; } + + return result.search.nodes.concat( + await getDiscussions(query, pageSize, result.search.pageInfo.endCursor, maxPages, currentPage + 1) + ); } /** @@ -128,19 +212,20 @@ async function getDiscussionByID(isPR: boolean, id: string): Promise + graphql(isPR ? Queries.pullRequestById : Queries.issueById, { + id, + headers: { + authorization: `token ${token}` + } + }), + `getDiscussionByID ${id}` + ); - return result; - } catch (error) { - logger.error(error); - throw error; - } + await adaptiveDelay(result.rateLimit); + + return result; } /** @@ -156,47 +241,50 @@ async function getDiscussionByID(isPR: boolean, id: string): Promise { - return Promise.all( - batch.map(async (discussion) => { - try { - // eslint-disable-next-line no-underscore-dangle - const isPR = discussion.__typename === 'PullRequest'; + const results: ProcessedDiscussion[] = []; - if (discussion.comments.pageInfo?.hasNextPage) { - const fetchedDiscussion = await getDiscussionByID(isPR, discussion.id); + for (const discussion of batch) { + try { + // eslint-disable-next-line no-underscore-dangle + const isPR = discussion.__typename === 'PullRequest'; + let item = discussion; - // eslint-disable-next-line no-param-reassign - discussion = fetchedDiscussion.node; - } + if (item.comments.pageInfo?.hasNextPage) { + // eslint-disable-next-line no-await-in-loop + const fetchedDiscussion = await getDiscussionByID(isPR, item.id); - const interactionsCount = - discussion.reactions.totalCount + - discussion.comments.totalCount + - discussion.comments.nodes.reduce((acc, curr) => acc + curr.reactions.totalCount, 0); - - const finalInteractionsCount = isPR - ? interactionsCount + - discussion.reviews.totalCount + - (discussion.reviews.nodes?.reduce((acc, curr) => acc + curr.comments.totalCount, 0) ?? 0) - : interactionsCount; - - return { - id: discussion.id, - isPR, - isAssigned: !!discussion.assignees.totalCount, - title: discussion.title, - author: discussion.author.login, - resourcePath: discussion.resourcePath, - repo: `asyncapi/${discussion.repository.name}`, - labels: discussion.labels ? discussion.labels.nodes : [], - score: finalInteractionsCount / (monthsSince(discussion.timelineItems.updatedAt) + 2) ** 1.8 - }; - } catch (error) { - logger.error(`there were some issues while parsing this item: ${JSON.stringify(discussion)}`); - throw error; + item = fetchedDiscussion.node; } - }) - ); + + const interactionsCount = + item.reactions.totalCount + + item.comments.totalCount + + item.comments.nodes.reduce((acc, curr) => acc + curr.reactions.totalCount, 0); + + const finalInteractionsCount = isPR + ? interactionsCount + + item.reviews.totalCount + + (item.reviews.nodes?.reduce((acc, curr) => acc + curr.comments.totalCount, 0) ?? 0) + : interactionsCount; + + results.push({ + id: item.id, + isPR, + isAssigned: !!item.assignees.totalCount, + title: item.title, + author: item.author.login, + resourcePath: item.resourcePath, + repo: `asyncapi/${item.repository.name}`, + labels: item.labels ? item.labels.nodes : [], + score: finalInteractionsCount / (monthsSince(item.timelineItems.updatedAt) + 2) ** 1.8 + }); + } catch (error) { + logger.error(`there were some issues while parsing this item: ${JSON.stringify(discussion)}`); + throw error; + } + } + + return results; } /** @@ -284,31 +372,91 @@ async function mapGoodFirstIssues(issues: GoodFirstIssues[]): Promise { + const cutoffDate = getHotDiscussionsCutoffDate(); + + logger.info(`Fetching hot discussions updated since ${cutoffDate} (${HOT_DISCUSSIONS_MONTHS_BACK} months back)`); + + let hotDiscussions: ProcessedDiscussion[] = []; + let goodFirstIssues: MappedIssue[] = []; + let hotDiscussionsFailed = false; + let goodFirstIssuesFailed = false; + + let hotIssues: Discussion['search']['nodes'] = []; + let hotPRs: Discussion['search']['nodes'] = []; + let hotIssuesFetchFailed = false; + let hotPRsFetchFailed = false; + + try { + hotIssues = await getDiscussions( + Queries.hotDiscussionsIssues(cutoffDate), + PAGE_SIZE, + null, + MAX_PAGES_HOT_DISCUSSIONS + ); + } catch (error) { + hotIssuesFetchFailed = true; + logger.error('Failed to fetch hot discussion issues:'); + logger.error(error); + } + + try { + hotPRs = await getDiscussions( + Queries.hotDiscussionsPullRequests(cutoffDate), + PAGE_SIZE, + null, + MAX_PAGES_HOT_DISCUSSIONS + ); + } catch (error) { + hotPRsFetchFailed = true; + logger.error('Failed to fetch hot discussion PRs:'); + logger.error(error); + } + + if (hotIssuesFetchFailed && hotPRsFetchFailed) { + hotDiscussionsFailed = true; + } else { + try { + hotDiscussions = await getHotDiscussions(hotIssues.concat(hotPRs)); + logger.info(`Collected ${hotDiscussions.length} hot discussions`); + } catch (error) { + hotDiscussionsFailed = true; + logger.error('Failed to process hot discussions:'); + logger.error(error); + } + } + try { - const issues = (await getDiscussions(Queries.hotDiscussionsIssues, 20)) as HotDiscussionsIssuesNode[]; - const PRs = (await getDiscussions(Queries.hotDiscussionsPullRequests, 20)) as HotDiscussionsPullRequestsNode[]; - const rawGoodFirstIssues: GoodFirstIssues[] = await getDiscussions(Queries.goodFirstIssues, 20); - const discussions = issues.concat(PRs); - const [hotDiscussions, goodFirstIssues] = await Promise.all([ - getHotDiscussions(discussions), - mapGoodFirstIssues(rawGoodFirstIssues) - ]); - - await writeToFile({ hotDiscussions, goodFirstIssues }, writePath); + const rawGoodFirstIssues: GoodFirstIssues[] = await getDiscussions( + Queries.goodFirstIssues, + PAGE_SIZE, + null, + MAX_PAGES_GOOD_FIRST_ISSUES + ); + + goodFirstIssues = await mapGoodFirstIssues(rawGoodFirstIssues); + logger.info(`Collected ${goodFirstIssues.length} good first issues`); } catch (error) { - logger.error('There were some issues parsing data from github.'); + goodFirstIssuesFailed = true; + logger.error('Failed to fetch good first issues:'); logger.error(error); } + + if (hotDiscussionsFailed && goodFirstIssuesFailed) { + throw new Error('Dashboard generation failed: unable to fetch any data from GitHub.'); + } + + if (hotDiscussionsFailed || goodFirstIssuesFailed) { + logger.warn('Dashboard generated with partial data due to errors above.'); + } + + await writeToFile({ hotDiscussions, goodFirstIssues }, writePath); } /* istanbul ignore next */ @@ -317,12 +465,16 @@ if (process.argv[1] === fileURLToPath(import.meta.url)) { } export { + adaptiveDelay, getDiscussionByID, getDiscussions, getHotDiscussions, + getHotDiscussionsCutoffDate, getLabel, + isRetryableError, mapGoodFirstIssues, processHotDiscussions, + retryWithBackoff, start, writeToFile }; diff --git a/scripts/dashboard/issue-queries.ts b/scripts/dashboard/issue-queries.ts index 7eafae9fbd3a..a763ba206516 100644 --- a/scripts/dashboard/issue-queries.ts +++ b/scripts/dashboard/issue-queries.ts @@ -1,5 +1,4 @@ -export const Queries = Object.freeze({ - pullRequestById: ` +const pullRequestById = ` query IssueByID($id: ID!) { node(id: $id) { __typename @@ -31,7 +30,7 @@ query IssueByID($id: ID!) { } } } - comments(first: 10) { + comments(first: 20) { totalCount pageInfo { hasNextPage @@ -50,8 +49,9 @@ query IssueByID($id: ID!) { remaining resetAt } -}`, - issueById: ` +}`; + +const issueById = ` query IssueByID($id: ID!) { node(id: $id) { __typename @@ -99,14 +99,15 @@ query IssueByID($id: ID!) { remaining resetAt } -}`, - goodFirstIssues: ` +}`; + +const goodFirstIssues = String.raw` query($first: Int!, $after: String) { search( first: $first after: $after type: ISSUE - query: "org:asyncapi state:open is:issue label:\\"good first issue\\"" + query: "org:asyncapi state:open is:issue label:\"good first issue\"" ) { pageInfo { hasNextPage @@ -143,14 +144,16 @@ query($first: Int!, $after: String) { resetAt } } -`, - hotDiscussionsIssues: ` +`; + +function hotDiscussionsIssues(updatedSince: string) { + return ` query($first: Int!, $after: String) { search( first: $first after: $after type: ISSUE - query: "org:asyncapi state:open is:issue" + query: "org:asyncapi state:open is:issue updated:>${updatedSince} sort:updated-desc" ) { pageInfo { hasNextPage @@ -204,14 +207,17 @@ query($first: Int!, $after: String) { resetAt } } -`, - hotDiscussionsPullRequests: ` +`; +} + +function hotDiscussionsPullRequests(updatedSince: string) { + return ` query($first: Int!, $after: String) { search( first: $first after: $after type: ISSUE - query: "org:asyncapi state:open is:pull-request" + query: "org:asyncapi state:open is:pull-request updated:>${updatedSince} sort:updated-desc" ) { pageInfo { hasNextPage @@ -253,7 +259,7 @@ query($first: Int!, $after: String) { } } } - comments(first: 10) { + comments(first: 20) { totalCount pageInfo { hasNextPage @@ -274,5 +280,13 @@ query($first: Int!, $after: String) { resetAt } } -` -}); +`; +} + +export const Queries = { + pullRequestById, + issueById, + goodFirstIssues, + hotDiscussionsIssues, + hotDiscussionsPullRequests +}; diff --git a/tests/build-newsroom-videos.test.ts b/tests/build-newsroom-videos.test.ts index 09b5c480aec4..5be2ea358446 100644 --- a/tests/build-newsroom-videos.test.ts +++ b/tests/build-newsroom-videos.test.ts @@ -14,7 +14,7 @@ jest.mock('node-fetch-2', () => { const mockFetch = fetch as jest.Mock; describe('buildNewsroomVideos', () => { - const testDir = join(os.tmpdir(), 'test_config'); + const testDir = join(os.tmpdir(), 'test_config_newsroom'); const testFilePath = resolve(testDir, 'newsroom_videos.json'); beforeAll(() => { diff --git a/tests/build-tools.test.ts b/tests/build-tools.test.ts index 6fbf11daa381..21bd90b033f3 100644 --- a/tests/build-tools.test.ts +++ b/tests/build-tools.test.ts @@ -29,7 +29,7 @@ jest.mock('../scripts/tools/tags-color', () => ({ })); describe('buildTools', () => { - const testDir = path.join(String(os.tmpdir()), 'test_config'); + const testDir = path.join(String(os.tmpdir()), 'test_config_tools'); const toolsPath = resolve(testDir, 'tools.json'); const tagsPath = resolve(testDir, 'all-tags.json'); const automatedToolsPath = resolve(testDir, 'tools-automated.json'); diff --git a/tests/dashboard/build-dashboard.test.ts b/tests/dashboard/build-dashboard.test.ts index 5fca4f915261..9d2f7c4fd920 100644 --- a/tests/dashboard/build-dashboard.test.ts +++ b/tests/dashboard/build-dashboard.test.ts @@ -3,35 +3,48 @@ import { mkdirSync, promises as fs, rmSync } from 'fs-extra'; import os from 'os'; import { resolve } from 'path'; -import type { GoodFirstIssues, HotDiscussionsIssuesNode } from '@/types/scripts/dashboard'; +import type { + GoodFirstIssues, + HotDiscussionsIssuesNode, +} from '@/types/scripts/dashboard'; import { + adaptiveDelay, getDiscussionByID, getDiscussions, getHotDiscussions, + getHotDiscussionsCutoffDate, getLabel, + isRetryableError, mapGoodFirstIssues, + retryWithBackoff, start, - writeToFile + writeToFile, } from '../../scripts/dashboard/build-dashboard'; import { logger } from '../../scripts/helpers/logger'; +import { pause } from '../../scripts/helpers/utils'; import { discussionWithMoreComments, fullDiscussionDetails, issues, mockDiscussion, - mockRateLimitResponse + mockHealthyRateLimitResponse, } from '../fixtures/dashboardData'; jest.mock('../../scripts/helpers/logger', () => ({ - logger: { error: jest.fn(), warn: jest.fn() } + logger: { error: jest.fn(), warn: jest.fn(), info: jest.fn() }, })); jest.mock('@octokit/graphql', () => ({ graphql: jest.fn(), })); -const mockedGraphql = graphql as unknown as jest.Mock; // Declare graphql as a mock type +jest.mock('../../scripts/helpers/utils', () => ({ + ...jest.requireActual('../../scripts/helpers/utils'), + pause: jest.fn().mockResolvedValue(undefined), +})); + +const mockedGraphql = graphql as unknown as jest.Mock; describe('GitHub Discussions Processing', () => { let tempDir: string; @@ -52,6 +65,7 @@ describe('GitHub Discussions Processing', () => { jest.resetAllMocks(); consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); + (pause as jest.Mock).mockResolvedValue(undefined); }); afterEach(() => { @@ -59,6 +73,19 @@ describe('GitHub Discussions Processing', () => { consoleLogSpy.mockRestore(); }); + const makePageResponse = (page: number, hasNext: boolean) => ({ + search: { + nodes: [{ ...mockDiscussion, id: `test-id-${page}` }], + pageInfo: { hasNextPage: hasNext, endCursor: `cursor${page}` }, + }, + rateLimit: { + remaining: 1000, + limit: 5000, + cost: 1, + resetAt: new Date().toISOString(), + }, + }); + it('should fetch additional discussion details when comments have next page', async () => { mockedGraphql.mockResolvedValueOnce(fullDiscussionDetails); @@ -68,14 +95,14 @@ describe('GitHub Discussions Processing', () => { expect.any(String), expect.objectContaining({ id: 'paginated-discussion', - headers: expect.any(Object) - }) + headers: expect.any(Object), + }), ); expect(result[0]).toMatchObject({ id: 'paginated-discussion', isPR: false, - title: 'Test with Pagination' + title: 'Test with Pagination', }); const firstResult = result[0]; @@ -83,51 +110,226 @@ describe('GitHub Discussions Processing', () => { expect(firstResult.score).toBeGreaterThan(0); }); - it('should handle rate limit warnings', async () => { - mockedGraphql.mockResolvedValueOnce(mockRateLimitResponse); + it('should apply adaptive delay based on rate limit remaining', async () => { + await adaptiveDelay({ + limit: 5000, + cost: 1, + remaining: 50, + resetAt: new Date().toISOString(), + }); + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining('Rate limit critically low'), + ); + expect(pause).toHaveBeenCalled(); + + (pause as jest.Mock).mockClear(); + (logger.warn as jest.Mock).mockClear(); + + await adaptiveDelay({ + limit: 5000, + cost: 1, + remaining: 300, + resetAt: new Date().toISOString(), + }); + expect(pause).toHaveBeenCalledWith(5000); - await getDiscussions('test-query', 10); + (pause as jest.Mock).mockClear(); + await adaptiveDelay({ + limit: 5000, + cost: 1, + remaining: 4000, + resetAt: new Date().toISOString(), + }); + expect(pause).toHaveBeenCalledWith(2000); + }); + + it('should cap adaptive delay at 15 minutes and handle invalid resetAt', async () => { + await adaptiveDelay({ + limit: 5000, + cost: 1, + remaining: 50, + resetAt: 'invalid-date', + }); expect(logger.warn).toHaveBeenCalledWith( - expect.stringContaining('GitHub GraphQL rateLimit \ncost = 1\nlimit = 5000\nremaining = 50') + expect.stringContaining('Rate limit critically low'), ); + const [waitArg] = (pause as jest.Mock).mock.calls[0]; + + expect(waitArg).toBeLessThanOrEqual(15 * 60_000); + expect(waitArg).toBeGreaterThan(0); + + (pause as jest.Mock).mockClear(); + (logger.warn as jest.Mock).mockClear(); + + const farFuture = new Date(Date.now() + 2 * 60 * 60_000).toISOString(); + + await adaptiveDelay({ + limit: 5000, + cost: 1, + remaining: 50, + resetAt: farFuture, + }); + const [cappedWait] = (pause as jest.Mock).mock.calls[0]; + + expect(cappedWait).toBeLessThanOrEqual(15 * 60_000); }); it('should handle pagination', async () => { - const mockFirstResponse = { - search: { - nodes: [mockDiscussion], - pageInfo: { hasNextPage: true, endCursor: 'cursor1' } - }, - rateLimit: { remaining: 1000 } - }; + mockedGraphql + .mockResolvedValueOnce(makePageResponse(1, true)) + .mockResolvedValueOnce(makePageResponse(2, false)); - const mockSecondResponse = { - search: { - nodes: [{ ...mockDiscussion, id: 'test-id-2' }], - pageInfo: { hasNextPage: false } - }, - rateLimit: { remaining: 1000 } - }; + const result = await getDiscussions('test-query', 10); + + expect(result).toHaveLength(2); + }); - mockedGraphql.mockResolvedValueOnce(mockFirstResponse).mockResolvedValueOnce(mockSecondResponse); + it('should respect maxPages parameter', async () => { + mockedGraphql + .mockResolvedValueOnce(makePageResponse(1, true)) + .mockResolvedValueOnce(makePageResponse(2, true)) + .mockResolvedValueOnce(makePageResponse(3, true)); - const result = await getDiscussions('test-query', 10); + const result = await getDiscussions('test-query', 10, null, 2); expect(result).toHaveLength(2); + expect(mockedGraphql).toHaveBeenCalledTimes(2); }); - it('should handle complete failure', async () => { + it('should not limit pages when maxPages is 0', async () => { + mockedGraphql + .mockResolvedValueOnce(makePageResponse(1, true)) + .mockResolvedValueOnce(makePageResponse(2, true)) + .mockResolvedValueOnce(makePageResponse(3, false)); + + const result = await getDiscussions('test-query', 10, null, 0); + + expect(result).toHaveLength(3); + expect(mockedGraphql).toHaveBeenCalledTimes(3); + }); + + it('should throw when both hot discussions and good first issues fail', async () => { mockedGraphql.mockRejectedValue(new Error('Complete API failure')); const filePath = resolve(tempDir, 'error-output.json'); + await expect(start(filePath)).rejects.toThrow( + 'Dashboard generation failed', + ); + expect(logger.error).toHaveBeenCalledWith( + 'Failed to fetch hot discussion issues:', + ); + expect(logger.error).toHaveBeenCalledWith( + 'Failed to fetch hot discussion PRs:', + ); + expect(logger.error).toHaveBeenCalledWith( + 'Failed to fetch good first issues:', + ); + }); + + it('should write partial data when only hot discussions fail', async () => { + let callCount = 0; + + mockedGraphql.mockImplementation(() => { + callCount++; + + // First two calls are hot discussion issues and PRs β€” both fail + if (callCount <= 2) { + return Promise.reject(new Error('Hot discussions API failure')); + } + + // Third call is good first issues β€” succeed + return Promise.resolve(mockHealthyRateLimitResponse); + }); + + const filePath = resolve(tempDir, 'partial-good-first.json'); + await start(filePath); - expect(logger.error).toHaveBeenCalledWith('There were some issues parsing data from github.'); + + const content = JSON.parse(await fs.readFile(filePath, 'utf-8')); + + expect(content.hotDiscussions).toEqual([]); + expect(content.goodFirstIssues).toHaveLength(1); + expect(logger.warn).toHaveBeenCalledWith( + 'Dashboard generated with partial data due to errors above.', + ); + }); + + it('should write partial data when hot discussion processing fails', async () => { + const malformedResponse = { + search: { + nodes: [{ id: 'bad-node' }], + pageInfo: { hasNextPage: false }, + }, + rateLimit: { + remaining: 4000, + limit: 5000, + cost: 1, + resetAt: new Date().toISOString(), + }, + }; + + let callCount = 0; + + mockedGraphql.mockImplementation(() => { + callCount++; + + // Hot issues and PRs return malformed data that will crash processing + if (callCount <= 2) { + return Promise.resolve(malformedResponse); + } + + // Good first issues succeeds normally + return Promise.resolve(mockHealthyRateLimitResponse); + }); + + const filePath = resolve(tempDir, 'partial-processing-fail.json'); + + await start(filePath); + + const content = JSON.parse(await fs.readFile(filePath, 'utf-8')); + + expect(content.hotDiscussions).toEqual([]); + expect(content.goodFirstIssues).toHaveLength(1); + expect(logger.error).toHaveBeenCalledWith( + 'Failed to process hot discussions:', + ); + expect(logger.warn).toHaveBeenCalledWith( + 'Dashboard generated with partial data due to errors above.', + ); + }); + + it('should write partial data when only good first issues fail', async () => { + let callCount = 0; + + mockedGraphql.mockImplementation(() => { + callCount++; + + // First two calls succeed (hot discussions issues + PRs) + if (callCount <= 2) { + return Promise.resolve(mockHealthyRateLimitResponse); + } + + // Third call for good first issues β€” fail + return Promise.reject(new Error('Good first issues API failure')); + }); + + const filePath = resolve(tempDir, 'partial-hot.json'); + + await start(filePath); + + const content = JSON.parse(await fs.readFile(filePath, 'utf-8')); + + expect(content.hotDiscussions).toBeDefined(); + expect(content.goodFirstIssues).toEqual([]); + expect(logger.warn).toHaveBeenCalledWith( + 'Dashboard generated with partial data due to errors above.', + ); }); it('should successfully process and write data', async () => { - mockedGraphql.mockResolvedValue(mockRateLimitResponse); + mockedGraphql.mockResolvedValue(mockHealthyRateLimitResponse); const filePath = resolve(tempDir, 'success-output.json'); @@ -141,7 +343,7 @@ describe('GitHub Discussions Processing', () => { it('should get labels correctly', () => { const issue = { - labels: { nodes: [{ name: 'area/bug' }, { name: 'good first issue' }] } + labels: { nodes: [{ name: 'area/bug' }, { name: 'good first issue' }] }, } as GoodFirstIssues; expect(getLabel(issue, 'area/')).toBe('bug'); @@ -153,7 +355,7 @@ describe('GitHub Discussions Processing', () => { expect(result[0]).toMatchObject({ id: '1', - area: 'docs' + area: 'docs', }); }); @@ -170,9 +372,9 @@ describe('GitHub Discussions Processing', () => { nodes: [ { name: 'area/documentation', color: '#0366d6' }, { name: 'good first issue', color: '#7057ff' }, - { name: 'bug', color: '#d73a4a' } - ] - } + { name: 'bug', color: '#d73a4a' }, + ], + }, }; const result = await mapGoodFirstIssues([mockIssue]); @@ -186,12 +388,20 @@ describe('GitHub Discussions Processing', () => { repo: 'asyncapi/test-repo', author: 'testuser', area: 'documentation', - labels: [{ name: 'bug', color: '#d73a4a' }] + labels: [{ name: 'bug', color: '#d73a4a' }], }); }); it('should handle discussion retrieval', async () => { - mockedGraphql.mockResolvedValueOnce({ node: mockDiscussion }); + mockedGraphql.mockResolvedValueOnce({ + node: mockDiscussion, + rateLimit: { + remaining: 4000, + limit: 5000, + cost: 1, + resetAt: new Date().toISOString(), + }, + }); const result = await getDiscussionByID(false, 'test-id'); expect(result.node).toBeDefined(); @@ -206,8 +416,13 @@ describe('GitHub Discussions Processing', () => { __typename: 'PullRequest', reviews: { totalCount: 1, - nodes: [{ lastEditedAt: new Date().toISOString(), comments: { totalCount: 1 } }] - } + nodes: [ + { + lastEditedAt: new Date().toISOString(), + comments: { totalCount: 1 }, + }, + ], + }, } as HotDiscussionsIssuesNode; const result = await getHotDiscussions([mockDiscussion, prDiscussion]); @@ -221,8 +436,8 @@ describe('GitHub Discussions Processing', () => { __typename: 'PullRequest', reviews: { totalCount: 1, - nodes: undefined // This will trigger the ?? 0 part - } + nodes: undefined, + }, }; const result = await getHotDiscussions([prDiscussion]); @@ -234,7 +449,7 @@ describe('GitHub Discussions Processing', () => { const prDiscussion = { ...mockDiscussion, __typename: 'PullRequest', - labels: null // This will trigger the ?? [] part + labels: null, }; const result = await getHotDiscussions([prDiscussion]); @@ -256,7 +471,9 @@ describe('GitHub Discussions Processing', () => { await expect(getHotDiscussions([undefined] as any)).rejects.toThrow(); - expect(logger.error).toHaveBeenCalledWith('there were some issues while parsing this item: undefined'); + expect(logger.error).toHaveBeenCalledWith( + 'there were some issues while parsing this item: undefined', + ); localConsoleErrorSpy.mockRestore(); }); @@ -269,34 +486,146 @@ describe('GitHub Discussions Processing', () => { it('should throw error when GITHUB_TOKEN is not set', async () => { delete process.env.GITHUB_TOKEN; - // getDiscussionsById and getDiscussions // @ts-expect-error - Intentionally calling without arguments to test missing token error - await expect(getDiscussionByID()).rejects.toThrow('GitHub token is not set in environment variables'); + await expect(getDiscussionByID()).rejects.toThrow( + 'GitHub token is not set in environment variables', + ); // @ts-expect-error - Intentionally calling without arguments to test missing token error - await expect(getDiscussions()).rejects.toThrow('GitHub token is not set in environment variables'); + await expect(getDiscussions()).rejects.toThrow( + 'GitHub token is not set in environment variables', + ); process.env.GITHUB_TOKEN = 'test-token'; }); it('should correctly calculate score based on months since update', async () => { - // Create two identical discussions but with different update timestamps const recentDiscussion = { ...mockDiscussion, - timelineItems: { updatedAt: new Date().toISOString() } + timelineItems: { updatedAt: new Date().toISOString() }, }; const olderDiscussion = { ...mockDiscussion, id: 'older-discussion', - timelineItems: { updatedAt: new Date(Date.now() - 6 * 30 * 24 * 60 * 60 * 1000).toISOString() } // 6 months ago + timelineItems: { + updatedAt: new Date( + Date.now() - 6 * 30 * 24 * 60 * 60 * 1000, + ).toISOString(), + }, }; const result = await getHotDiscussions([recentDiscussion, olderDiscussion]); - // The recent discussion should have a higher score than the older one const recentScore = result.find((d) => d.id === mockDiscussion.id)!.score; const olderScore = result.find((d) => d.id === 'older-discussion')!.score; expect(recentScore).toBeGreaterThan(olderScore); }); }); + +describe('isRetryableError', () => { + it('should detect secondary rate limit errors', () => { + expect( + isRetryableError(new Error('You have exceeded a secondary rate limit.')), + ).toBe(true); + expect(isRetryableError(new Error('SECONDARY RATE LIMIT exceeded'))).toBe( + true, + ); + }); + + it('should detect server errors (502, Unicorn)', () => { + expect(isRetryableError(new Error('502 Bad Gateway'))).toBe(true); + expect(isRetryableError(new Error('Unicorn! Something went wrong'))).toBe( + true, + ); + }); + + it('should detect network errors', () => { + expect(isRetryableError(new Error('ECONNRESET'))).toBe(true); + expect(isRetryableError(new Error('ETIMEDOUT'))).toBe(true); + }); + + it('should not retry non-retryable errors', () => { + expect(isRetryableError(new Error('Some other error'))).toBe(false); + expect(isRetryableError(null)).toBe(false); + }); +}); + +describe('getHotDiscussionsCutoffDate', () => { + it('should return a date string in YYYY-MM-DD format', () => { + const cutoff = getHotDiscussionsCutoffDate(); + + expect(cutoff).toMatch(/^\d{4}-\d{2}-\d{2}$/); + }); + + it('should return a date in the past', () => { + const cutoff = getHotDiscussionsCutoffDate(); + const cutoffDate = new Date(cutoff); + + expect(cutoffDate.getTime()).toBeLessThan(Date.now()); + }); +}); + +describe('retryWithBackoff', () => { + beforeEach(() => { + jest.resetAllMocks(); + (pause as jest.Mock).mockResolvedValue(undefined); + }); + + it('should return result on first successful attempt', async () => { + const fn = jest.fn().mockResolvedValue('success'); + + const result = await retryWithBackoff(fn, 'test'); + + expect(result).toBe('success'); + expect(fn).toHaveBeenCalledTimes(1); + }); + + it('should retry on secondary rate limit error and succeed', async () => { + const fn = jest + .fn() + .mockRejectedValueOnce( + new Error('You have exceeded a secondary rate limit.'), + ) + .mockResolvedValueOnce('success'); + + const result = await retryWithBackoff(fn, 'test'); + + expect(result).toBe('success'); + expect(fn).toHaveBeenCalledTimes(2); + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining('Retryable error during test'), + ); + }); + + it('should throw after exhausting retries with a descriptive message', async () => { + const fn = jest + .fn() + .mockRejectedValue( + new Error('You have exceeded a secondary rate limit.'), + ); + + await expect(retryWithBackoff(fn, 'test')).rejects.toThrow( + 'Exhausted 3 retries for test: You have exceeded a secondary rate limit.', + ); + expect(fn).toHaveBeenCalledTimes(4); // 1 initial + 3 retries + }); + + it('should wrap non-Error thrown values in the exhaustion message', async () => { + const fn = jest.fn().mockRejectedValue('secondary rate limit hit'); + + await expect(retryWithBackoff(fn, 'test')).rejects.toThrow( + 'Exhausted 3 retries for test: secondary rate limit hit', + ); + expect(fn).toHaveBeenCalledTimes(4); + }); + + it('should not retry non-retryable errors', async () => { + const fn = jest.fn().mockRejectedValue(new Error('Network timeout')); + + await expect(retryWithBackoff(fn, 'test')).rejects.toThrow( + 'Network timeout', + ); + expect(fn).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/fixtures/dashboardData.ts b/tests/fixtures/dashboardData.ts index 1e801660cd22..30ef56735bc8 100644 --- a/tests/fixtures/dashboardData.ts +++ b/tests/fixtures/dashboardData.ts @@ -12,14 +12,14 @@ const mockDiscussion = { comments: { totalCount: 2, nodes: [{ reactions: { totalCount: 1 } }], - pageInfo: { hasNextPage: false } + pageInfo: { hasNextPage: false }, }, labels: { nodes: [] }, timelineItems: { updatedAt: new Date().toISOString() }, reviews: { totalCount: 0, - nodes: [] - } + nodes: [], + }, }; const discussionWithMoreComments = { @@ -34,14 +34,14 @@ const discussionWithMoreComments = { comments: { totalCount: 5, nodes: [{ reactions: { totalCount: 1 } }], - pageInfo: { hasNextPage: true } + pageInfo: { hasNextPage: true }, }, labels: { nodes: [] }, timelineItems: { updatedAt: new Date().toISOString() }, reviews: { totalCount: 0, - nodes: [] - } + nodes: [], + }, }; const fullDiscussionDetails = { @@ -49,23 +49,33 @@ const fullDiscussionDetails = { ...discussionWithMoreComments, comments: { totalCount: 5, - nodes: [{ reactions: { totalCount: 1 } }, { reactions: { totalCount: 2 } }, { reactions: { totalCount: 3 } }], - pageInfo: { hasNextPage: false } - } - } + nodes: [ + { reactions: { totalCount: 1 } }, + { reactions: { totalCount: 2 } }, + { reactions: { totalCount: 3 } }, + ], + pageInfo: { hasNextPage: false }, + }, + }, + rateLimit: { + cost: 1, + limit: 5000, + remaining: 4000, + resetAt: new Date(Date.now() + 3600000).toISOString(), + }, }; -const mockRateLimitResponse = { +const mockHealthyRateLimitResponse = { search: { nodes: [mockDiscussion], - pageInfo: { hasNextPage: false } + pageInfo: { hasNextPage: false }, }, rateLimit: { cost: 1, limit: 5000, - remaining: 50, - resetAt: new Date().toISOString() - } + remaining: 4000, + resetAt: new Date(Date.now() + 3600000).toISOString(), + }, }; const issues = [ @@ -76,8 +86,14 @@ const issues = [ resourcePath: '/path', repository: { name: 'repo' }, author: { login: 'author' }, - labels: { nodes: [{ name: 'area/docs' }] } - } + labels: { nodes: [{ name: 'area/docs' }] }, + }, ] as GoodFirstIssues[]; -export { discussionWithMoreComments, fullDiscussionDetails, issues, mockDiscussion, mockRateLimitResponse }; +export { + discussionWithMoreComments, + fullDiscussionDetails, + issues, + mockDiscussion, + mockHealthyRateLimitResponse, +}; diff --git a/types/scripts/dashboard.ts b/types/scripts/dashboard.ts index 196f48c57588..77a36fbc9ec9 100644 --- a/types/scripts/dashboard.ts +++ b/types/scripts/dashboard.ts @@ -73,6 +73,7 @@ export interface PullRequestById { timelineItems: TimelineItems; comments: Comments; } & BasicIssueOrPR; + rateLimit: RateLimit; } export interface IssueById { @@ -82,6 +83,7 @@ export interface IssueById { comments: Comments; reviews: Reviews; } & BasicIssueOrPR; + rateLimit: RateLimit; } export interface GoodFirstIssues extends BasicIssueOrPR {}