From 6a559ecc5a927cc9ce029830daadb128a7a16656 Mon Sep 17 00:00:00 2001 From: Shawn Tice Date: Wed, 17 Jun 2026 15:27:29 -0500 Subject: [PATCH 1/6] Make refresh-* backfill tolerant of transient GitHub errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bulk backfills (refresh-all-issues etc.) make thousands of GitHub calls over ~25 minutes, but a single transient 5xx/network error aborted the entire sweep with no resume, so a clean pass rarely completed and iFixit/ifixit kept ending up un-backfilled. Harden three failure layers: - Request layer: add @octokit/plugin-retry so transient 5xx/network failures auto-retry (request: { retries: 5 }). Throttling still owns rate limits; the two plugins compose. - Fetch fan-out: forEachRepo now uses Promise.allSettled, so one repo's failure no longer aborts the others. It resolves { items, failedRepos } and logs each skipped repo. - Parse/db queue: extract the per-item handlers (processIssueItem / processPullItem) and catch parse/update failures — log and continue to the next item instead of crashing the process (which also hardens the live server against webhook-driven parse errors). So a partial backfill isn't mistaken for a complete one, the four bulk bin scripts report any skipped repos and exit non-zero (reportFailedRepos). Add a node:test suite (no new runtime dep) covering the two isolation paths, with a test fixture config selected via CONFIG_PATH. Co-Authored-By: Claude Opus 4.8 (1M context) --- bin/refresh-all-issues | 5 +- bin/refresh-all-pulls | 5 +- bin/refresh-open-issues | 5 +- bin/refresh-open-pulls | 5 +- lib/git-manager.js | 7 +- lib/refresh.js | 105 +++++++++++++++++++----- lib/utils.js | 33 +++++++- package-lock.json | 162 ++++++++++++++++++++++++++++++++++--- package.json | 3 +- test/fixtures/config.js | 66 +++++++++++++++ test/forEachRepo.test.js | 29 +++++++ test/refresh-queue.test.js | 47 +++++++++++ 12 files changed, 426 insertions(+), 46 deletions(-) create mode 100644 test/fixtures/config.js create mode 100644 test/forEachRepo.test.js create mode 100644 test/refresh-queue.test.js diff --git a/bin/refresh-all-issues b/bin/refresh-all-issues index 6254fbc2..3b1eac21 100755 --- a/bin/refresh-all-issues +++ b/bin/refresh-all-issues @@ -1,11 +1,12 @@ #!/usr/bin/env node import { default as debugInit } from '../lib/debug.js'; -import refresh from '../lib/refresh.js'; +import refresh, { reportFailedRepos } from '../lib/refresh.js'; import db from '../lib/db.js'; debugInit('pulldasher:refresh*'); refresh.allIssues() - .done(function () { + .done(function (result) { + reportFailedRepos(result); db.end(); }); diff --git a/bin/refresh-all-pulls b/bin/refresh-all-pulls index 5c3cb234..50e7a1b3 100755 --- a/bin/refresh-all-pulls +++ b/bin/refresh-all-pulls @@ -1,11 +1,12 @@ #!/usr/bin/env node import { default as debugInit } from '../lib/debug.js'; -import refresh from '../lib/refresh.js'; +import refresh, { reportFailedRepos } from '../lib/refresh.js'; import db from '../lib/db.js'; debugInit('pulldasher:refresh*'); refresh.allPulls() - .done(function () { + .done(function (result) { + reportFailedRepos(result); db.end(); }); diff --git a/bin/refresh-open-issues b/bin/refresh-open-issues index c8a77acd..1668fe9f 100755 --- a/bin/refresh-open-issues +++ b/bin/refresh-open-issues @@ -1,11 +1,12 @@ #!/usr/bin/env node import { default as debugInit } from '../lib/debug.js'; -import refresh from '../lib/refresh.js'; +import refresh, { reportFailedRepos } from '../lib/refresh.js'; import db from '../lib/db.js'; debugInit('pulldasher:refresh*'); refresh.openIssues() - .done(function () { + .done(function (result) { + reportFailedRepos(result); db.end(); }); diff --git a/bin/refresh-open-pulls b/bin/refresh-open-pulls index 5ffb1d9d..e258f606 100755 --- a/bin/refresh-open-pulls +++ b/bin/refresh-open-pulls @@ -1,11 +1,12 @@ #!/usr/bin/env node import { default as debugInit } from '../lib/debug.js'; -import refresh from '../lib/refresh.js'; +import refresh, { reportFailedRepos } from '../lib/refresh.js'; import db from '../lib/db.js'; debugInit('pulldasher:refresh*'); refresh.openPulls() - .done(function () { + .done(function (result) { + reportFailedRepos(result); db.end(); }); diff --git a/lib/git-manager.js b/lib/git-manager.js index 1181a577..7bf47bda 100644 --- a/lib/git-manager.js +++ b/lib/git-manager.js @@ -1,5 +1,6 @@ import { Octokit } from "@octokit/rest"; import { throttling } from "@octokit/plugin-throttling"; +import { retry } from "@octokit/plugin-retry"; import config from "./config-loader.js"; import Promise from "bluebird"; import _ from "underscore"; @@ -14,13 +15,17 @@ import Status from "../models/status.js"; import Signature from "../models/signature.js"; import getLogin from "./get-user-login.js"; -const MyOctokit = Octokit.plugin(throttling); +const MyOctokit = Octokit.plugin(throttling, retry); const gitDebug = debug("pulldasher:github"); console.log(config.github); const github = new MyOctokit({ auth: config.github.token, + // Auto-retry transient 5xx/network failures (the throttle plugin only covers + // rate limits); the long bulk backfills make thousands of calls, so a single + // transient error shouldn't abort the sweep before it can be reissued. + request: { retries: 5 }, throttle: { onRateLimit: (retryAfter, options) => { github.log.warn( diff --git a/lib/refresh.js b/lib/refresh.js index d2ba9ad6..3dcef2d8 100644 --- a/lib/refresh.js +++ b/lib/refresh.js @@ -23,14 +23,14 @@ export default { refreshDebug("refresh all issues"); return utils .forEachRepo(gitManager.getAllIssues) - .then(pushAllOnQueue(issueQueue)); + .then(drainThrough(issueQueue)); }, openIssues: function refreshOpenIssues() { refreshDebug("refresh all open issues"); return utils .forEachRepo(gitManager.getOpenIssues) - .then(pushAllOnQueue(issueQueue)); + .then(drainThrough(issueQueue)); }, /////// Pulls ///////// @@ -44,14 +44,14 @@ export default { refreshDebug("refresh all pull"); return utils .forEachRepo(gitManager.getAllPulls) - .then(pushAllOnQueue(pullQueue)); + .then(drainThrough(pullQueue)); }, openPulls: function refreshOpenPulls() { refreshDebug("refresh all open pulls"); return utils .forEachRepo(gitManager.getOpenPulls) - .then(pushAllOnQueue(pullQueue)); + .then(drainThrough(pullQueue)); }, }; @@ -86,7 +86,41 @@ function pushAllOnQueue(queue) { }; } -issueQueue.pop(function (githubIssue, next) { +/** + * For the bulk bin scripts: log any repos that couldn't be fetched and flag a + * non-zero exit, so a partial backfill (e.g. one repo down on transient errors) + * isn't mistaken for a complete one. + */ +export function reportFailedRepos({ failedRepos }) { + if (failedRepos.length) { + // One repo per line so the skipped set is greppable in the container logs. + failedRepos.forEach(function (repo) { + console.error("Skipped repo (could not fetch): %s", repo); + }); + process.exitCode = 1; + } +} + +/** + * Bridges a forEachRepo result onto a queue: pushes the fetched items, waits + * for them to drain, then resolves to `{ failedRepos }` so the bulk bin scripts + * can report (and exit non-zero on) repos that couldn't be fetched. + */ +function drainThrough(queue) { + return function ({ items, failedRepos }) { + return pushAllOnQueue(queue)(items).then(function () { + return { failedRepos }; + }); + }; +} + +/** + * Process one item off the issue queue. Dependencies are injected so the + * failure path is testable. A transient parse/update error is logged and + * swallowed — we still call next() so one bad issue can't abort the sweep (or, + * at runtime, crash the server on a webhook-driven refresh). + */ +export function processIssueItem(githubIssue, next, { parseIssue, updateAllIssueData }) { // Allow callers to push functions on the queue to signal when an item has // made it through if (typeof githubIssue === "function") { @@ -94,36 +128,67 @@ issueQueue.pop(function (githubIssue, next) { return next(); } refreshDebug("refreshing issue %s", githubIssue.number); - gitManager - .parseIssue(githubIssue) - .then(dbManager.updateAllIssueData) - .done(function () { + return parseIssue(githubIssue) + .then(updateAllIssueData) + .then(function () { refreshDebug( "done refreshing issue %s in repo %s", githubIssue.number, githubIssue.repo ); next(); + }) + .catch(function (err) { + console.error( + "Failed to refresh issue %s in repo %s: %s", + githubIssue.number, + githubIssue.repo, + (err && err.message) || err + ); + next(); }); -}); +} -pullQueue.pop(function (githubPull, next) { +/** + * Process one item off the pull queue. See processIssueItem. + */ +export function processPullItem(githubPull, next, { parse, updateAllPullData }) { // Allow callers to push functions on the queue to signal when an item has // made it through if (typeof githubPull === "function") { githubPull(); return next(); } - refreshDebug( - "refreshing pull %s in repo %s", - githubPull.number, - githubPull.base.repo.full_name - ); - gitManager - .parse(githubPull) - .then(dbManager.updateAllPullData) - .done(function () { + var repo = + githubPull.base && githubPull.base.repo && githubPull.base.repo.full_name; + refreshDebug("refreshing pull %s in repo %s", githubPull.number, repo); + return parse(githubPull) + .then(updateAllPullData) + .then(function () { refreshDebug("done refreshing pull %s", githubPull.number); next(); + }) + .catch(function (err) { + console.error( + "Failed to refresh pull %s in repo %s: %s", + githubPull.number, + repo, + (err && err.message) || err + ); + next(); }); +} + +issueQueue.pop(function (githubIssue, next) { + processIssueItem(githubIssue, next, { + parseIssue: gitManager.parseIssue, + updateAllIssueData: dbManager.updateAllIssueData, + }); +}); + +pullQueue.pop(function (githubPull, next) { + processPullItem(githubPull, next, { + parse: gitManager.parse, + updateAllPullData: dbManager.updateAllPullData, + }); }); diff --git a/lib/utils.js b/lib/utils.js index 01b02b40..4d1920e5 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -50,17 +50,42 @@ export default { * repository argument will be dealt with by this function. * * The function should take a repository name, and return an array of values. + * + * Resolves to `{ items, failedRepos }`: the flattened values from the repos + * that succeeded, plus the names of any that rejected. We use allSettled + * rather than Promise.all so one repo's transient failure (e.g. a 502 deep in + * a paginated list) doesn't abort the whole sweep — the caller decides how to + * report `failedRepos`. */ forEachRepo: function (singleRepoLambda, args) { // default value args = args || []; args.unshift(null); - var allRepoMap = function (currentRepo) { + var repos = config.repos; + var promises = repos.map(function (currentRepo) { args[0] = currentRepo.name; return singleRepoLambda.apply(this, args); - }; - return Promise.all(config.repos.map(allRepoMap)).then(function (repoItems) { - return _.flatten(repoItems, /* shallow */ true); + }); + return Promise.allSettled(promises).then(function (results) { + var repoItems = []; + var failedRepos = []; + results.forEach(function (result, i) { + if (result.status === 'fulfilled') { + repoItems.push(result.value); + } else { + var repoName = repos[i].name; + failedRepos.push(repoName); + console.error( + 'forEachRepo: failed to fetch repo %s: %s', + repoName, + (result.reason && result.reason.message) || result.reason + ); + } + }); + return { + items: _.flatten(repoItems, /* shallow */ true), + failedRepos: failedRepos, + }; }); }, diff --git a/package-lock.json b/package-lock.json index df7d03f5..68517c20 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "@fortawesome/fontawesome-svg-core": "^6.1.1", "@fortawesome/free-solid-svg-icons": "^6.0.0", "@fortawesome/react-fontawesome": "^0.1.17", + "@octokit/plugin-retry": "^7.2.1", "@octokit/plugin-throttling": "^9.4.0", "@octokit/rest": "^21.1.0", "@types/underscore": "^1.10.24", @@ -94,6 +95,7 @@ "version": "7.29.6", "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.6.tgz", "integrity": "sha512-QdxmAo/ikZqqRGA8s43ww8lcql6naWRvEz0FFrl6MIlc7Gi6TroXnSdWa5U/kq6fzcpqpHesicQxFZIieZbyIA==", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.6", @@ -2362,6 +2364,7 @@ "version": "1.12.0", "resolved": "https://registry.npmjs.org/@chakra-ui/system/-/system-1.12.0.tgz", "integrity": "sha512-yKX7T0KGo39YXAVMIdJB3PKzkStIblPAPLy7BIho1dK8ja8LpcB/HmQMioJocvQgD/0bV3sBls/v5So9Jb9PYQ==", + "peer": true, "dependencies": { "@chakra-ui/color-mode": "1.4.7", "@chakra-ui/react-utils": "1.2.3", @@ -2433,6 +2436,7 @@ "version": "1.14.0", "resolved": "https://registry.npmjs.org/@chakra-ui/theme/-/theme-1.14.0.tgz", "integrity": "sha512-zKy/8JSbiCP0QeBsLzdub7aBnfX2k0qp5vD+RA+mxPEiykEvPGg+TwryxRM5KMZK1Zdgg95aH+9mwiGe9tJt3A==", + "peer": true, "dependencies": { "@chakra-ui/anatomy": "1.3.0", "@chakra-ui/theme-tools": "1.3.6", @@ -2602,6 +2606,7 @@ "version": "11.9.0", "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.9.0.tgz", "integrity": "sha512-lBVSF5d0ceKtfKCDQJveNAtkC7ayxpVlgOohLgXqRwqWr9bOf4TZAFFyIcNngnV6xK6X4x2ZeXq7vliHkoVkxQ==", + "peer": true, "dependencies": { "@babel/runtime": "^7.13.10", "@emotion/babel-plugin": "^11.7.1", @@ -2645,6 +2650,7 @@ "version": "11.8.1", "resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.8.1.tgz", "integrity": "sha512-OghEVAYBZMpEquHZwuelXcRjRJQOVayvbmNR0zr174NHdmMgrNkLC6TljKC5h9lZLkN5WGrdUcrKlOJ4phhoTQ==", + "peer": true, "dependencies": { "@babel/runtime": "^7.13.10", "@emotion/babel-plugin": "^11.7.1", @@ -2742,6 +2748,7 @@ "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-6.1.1.tgz", "integrity": "sha512-NCg0w2YIp81f4V6cMGD9iomfsIj7GWrqmsa0ZsPh59G7PKiGN1KymZNxmF00ssuAlo/VZmpK6xazsGOwzKYUMg==", "hasInstallScript": true, + "peer": true, "dependencies": { "@fortawesome/fontawesome-common-types": "6.1.1" }, @@ -3020,6 +3027,7 @@ "resolved": "https://registry.npmjs.org/@octokit/core/-/core-6.1.4.tgz", "integrity": "sha512-lAS9k7d6I0MPN+gb9bKDt7X8SdxknYqAMh44S5L+lNqIN2NuV8nvv3g8rPp7MuRxcOpxpUIATWprO0C34a8Qmg==", "license": "MIT", + "peer": true, "dependencies": { "@octokit/auth-token": "^5.0.0", "@octokit/graphql": "^8.1.2", @@ -3108,6 +3116,38 @@ "@octokit/core": ">=6" } }, + "node_modules/@octokit/plugin-retry": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/@octokit/plugin-retry/-/plugin-retry-7.2.1.tgz", + "integrity": "sha512-wUc3gv0D6vNHpGxSaR3FlqJpTXGWgqmk607N9L3LvPL4QjaxDgX/1nY2mGpT37Khn+nlIXdljczkRnNdTTV3/A==", + "license": "MIT", + "dependencies": { + "@octokit/request-error": "^6.1.8", + "@octokit/types": "^14.0.0", + "bottleneck": "^2.15.3" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "@octokit/core": ">=6" + } + }, + "node_modules/@octokit/plugin-retry/node_modules/@octokit/openapi-types": { + "version": "25.1.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-25.1.0.tgz", + "integrity": "sha512-idsIggNXUKkk0+BExUn1dQ92sfysJrje03Q0bv0e+KPLrvyqZF8MnBpFz8UNfYDwB3Ie7Z0TByjWfzxt7vseaA==", + "license": "MIT" + }, + "node_modules/@octokit/plugin-retry/node_modules/@octokit/types": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-14.1.0.tgz", + "integrity": "sha512-1y6DgTy8Jomcpu33N+p5w58l6xyt55Ar2I91RPiIA0xCJBXyUAhXCcmZaDWSANiha7R9a6qJJ2CRomGPZ6f46g==", + "license": "MIT", + "dependencies": { + "@octokit/openapi-types": "^25.1.0" + } + }, "node_modules/@octokit/plugin-throttling": { "version": "9.4.0", "resolved": "https://registry.npmjs.org/@octokit/plugin-throttling/-/plugin-throttling-9.4.0.tgz", @@ -3141,17 +3181,32 @@ } }, "node_modules/@octokit/request-error": { - "version": "6.1.7", - "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-6.1.7.tgz", - "integrity": "sha512-69NIppAwaauwZv6aOzb+VVLwt+0havz9GT5YplkeJv7fG7a40qpLt/yZKyiDxAhgz0EtgNdNcb96Z0u+Zyuy2g==", + "version": "6.1.8", + "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-6.1.8.tgz", + "integrity": "sha512-WEi/R0Jmq+IJKydWlKDmryPcmdYSVjL3ekaiEL1L9eo1sUnqMJ+grqmC9cjk7CA7+b2/T397tO5d8YLOH3qYpQ==", "license": "MIT", "dependencies": { - "@octokit/types": "^13.6.2" + "@octokit/types": "^14.0.0" }, "engines": { "node": ">= 18" } }, + "node_modules/@octokit/request-error/node_modules/@octokit/openapi-types": { + "version": "25.1.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-25.1.0.tgz", + "integrity": "sha512-idsIggNXUKkk0+BExUn1dQ92sfysJrje03Q0bv0e+KPLrvyqZF8MnBpFz8UNfYDwB3Ie7Z0TByjWfzxt7vseaA==", + "license": "MIT" + }, + "node_modules/@octokit/request-error/node_modules/@octokit/types": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-14.1.0.tgz", + "integrity": "sha512-1y6DgTy8Jomcpu33N+p5w58l6xyt55Ar2I91RPiIA0xCJBXyUAhXCcmZaDWSANiha7R9a6qJJ2CRomGPZ6f46g==", + "license": "MIT", + "dependencies": { + "@octokit/openapi-types": "^25.1.0" + } + }, "node_modules/@octokit/rest": { "version": "21.1.0", "resolved": "https://registry.npmjs.org/@octokit/rest/-/rest-21.1.0.tgz", @@ -3579,6 +3634,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.43.tgz", "integrity": "sha512-8Q+LNpdxf057brvPu1lMtC5Vn7J119xrP1aq4qiaefNioQUYANF/CYeK4NsKorSZyUGJ66g0IM+4bbjwx45o2A==", "devOptional": true, + "peer": true, "dependencies": { "@types/prop-types": "*", "@types/scheduler": "*", @@ -3716,6 +3772,7 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.18.0.tgz", "integrity": "sha512-+08nYfurBzSSPndngnHvFw/fniWYJ5ymOrn/63oMIbgomVQOvIDhBoJmYZ9lwQOCnQV9xHGvf88ze3jFGUYooQ==", "dev": true, + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "5.18.0", "@typescript-eslint/types": "5.18.0", @@ -4099,6 +4156,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4145,6 +4203,7 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -4642,6 +4701,7 @@ "url": "https://github.com/sponsors/ai" } ], + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -5801,6 +5861,7 @@ "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.12.0.tgz", "integrity": "sha512-it1oBL9alZg1S8UycLm5YDMAkIhtH6FtAzuZs6YvoGVldWjbS08BkAdb/ymP9LlAyq8koANu32U7Ib/w+UNh8Q==", "dev": true, + "peer": true, "dependencies": { "@eslint/eslintrc": "^1.2.1", "@humanwhocodes/config-array": "^0.9.2", @@ -6729,6 +6790,7 @@ "version": "4.1.17", "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-4.1.17.tgz", "integrity": "sha512-thx1wvKzblzbs0XaK2X0G1JuwIdARcoNOW7VVwjO8BUltzXPyONGAElLu6CiCScsOQRI7FIk/45YTFtJw5Yozw==", + "peer": true, "dependencies": { "framesync": "5.3.0", "hey-listen": "^1.0.8", @@ -9349,6 +9411,7 @@ "url": "https://github.com/sponsors/ai" } ], + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -9627,6 +9690,7 @@ "version": "17.0.2", "resolved": "https://registry.npmjs.org/react/-/react-17.0.2.tgz", "integrity": "sha512-gnhPt75i/dq/z3/6q/0asP78D0u592D5L1pd7M8P+dck6Fu/jJeL6iVVK23fptSUZj8Vjf++7wXA8UNclGQcbA==", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "object-assign": "^4.1.1" @@ -9650,6 +9714,7 @@ "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-17.0.2.tgz", "integrity": "sha512-s4h96KtLDUQlsENhMn1ar8t2bEa+q/YAtj8pPPdIjPDGBDIVNsrD9aXNWqspUe6AzKCIG0C1HZZLqLV7qpOBGA==", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "object-assign": "^4.1.1", @@ -10986,6 +11051,7 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -11247,7 +11313,8 @@ "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "peer": true }, "node_modules/tsutils": { "version": "3.21.0", @@ -11329,6 +11396,7 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.6.3.tgz", "integrity": "sha512-yNIatDa5iaofVozS/uQJEl3JRWLKKGJKh6Yaiv0GLGSuhpFJe7P3SbHZ8/yjAHRQwKRoA6YZqlfjXWmVzoVSMw==", "dev": true, + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -11580,6 +11648,7 @@ "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.105.0.tgz", "integrity": "sha512-gX/dMkRQc7QOMzgTe6KsYFM7DxeIONQSui1s0n/0xht36HvrgbxtM1xBlgx596NbpHuQU8P7QpKwrZYwUX48nw==", "dev": true, + "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", @@ -11629,6 +11698,7 @@ "integrity": "sha512-MfwFQ6SfwinsUVi0rNJm7rHZ31GyTcpVE5pgVA3hwFRb7COD4TzjUUwhGWKfO50+xdc2MQPuEBBJoqIMGt3JDw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@discoveryjs/json-ext": "^0.6.1", "@webpack-cli/configtest": "^3.0.1", @@ -11710,6 +11780,7 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -11820,6 +11891,7 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -11906,6 +11978,7 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -12187,6 +12260,7 @@ "version": "7.29.6", "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.6.tgz", "integrity": "sha512-QdxmAo/ikZqqRGA8s43ww8lcql6naWRvEz0FFrl6MIlc7Gi6TroXnSdWa5U/kq6fzcpqpHesicQxFZIieZbyIA==", + "peer": true, "requires": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.6", @@ -13781,6 +13855,7 @@ "version": "1.12.0", "resolved": "https://registry.npmjs.org/@chakra-ui/system/-/system-1.12.0.tgz", "integrity": "sha512-yKX7T0KGo39YXAVMIdJB3PKzkStIblPAPLy7BIho1dK8ja8LpcB/HmQMioJocvQgD/0bV3sBls/v5So9Jb9PYQ==", + "peer": true, "requires": { "@chakra-ui/color-mode": "1.4.7", "@chakra-ui/react-utils": "1.2.3", @@ -13831,6 +13906,7 @@ "version": "1.14.0", "resolved": "https://registry.npmjs.org/@chakra-ui/theme/-/theme-1.14.0.tgz", "integrity": "sha512-zKy/8JSbiCP0QeBsLzdub7aBnfX2k0qp5vD+RA+mxPEiykEvPGg+TwryxRM5KMZK1Zdgg95aH+9mwiGe9tJt3A==", + "peer": true, "requires": { "@chakra-ui/anatomy": "1.3.0", "@chakra-ui/theme-tools": "1.3.6", @@ -13964,6 +14040,7 @@ "version": "11.9.0", "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.9.0.tgz", "integrity": "sha512-lBVSF5d0ceKtfKCDQJveNAtkC7ayxpVlgOohLgXqRwqWr9bOf4TZAFFyIcNngnV6xK6X4x2ZeXq7vliHkoVkxQ==", + "peer": true, "requires": { "@babel/runtime": "^7.13.10", "@emotion/babel-plugin": "^11.7.1", @@ -13995,6 +14072,7 @@ "version": "11.8.1", "resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.8.1.tgz", "integrity": "sha512-OghEVAYBZMpEquHZwuelXcRjRJQOVayvbmNR0zr174NHdmMgrNkLC6TljKC5h9lZLkN5WGrdUcrKlOJ4phhoTQ==", + "peer": true, "requires": { "@babel/runtime": "^7.13.10", "@emotion/babel-plugin": "^11.7.1", @@ -14061,6 +14139,7 @@ "version": "6.1.1", "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-6.1.1.tgz", "integrity": "sha512-NCg0w2YIp81f4V6cMGD9iomfsIj7GWrqmsa0ZsPh59G7PKiGN1KymZNxmF00ssuAlo/VZmpK6xazsGOwzKYUMg==", + "peer": true, "requires": { "@fortawesome/fontawesome-common-types": "6.1.1" } @@ -14245,6 +14324,7 @@ "version": "6.1.4", "resolved": "https://registry.npmjs.org/@octokit/core/-/core-6.1.4.tgz", "integrity": "sha512-lAS9k7d6I0MPN+gb9bKDt7X8SdxknYqAMh44S5L+lNqIN2NuV8nvv3g8rPp7MuRxcOpxpUIATWprO0C34a8Qmg==", + "peer": true, "requires": { "@octokit/auth-token": "^5.0.0", "@octokit/graphql": "^8.1.2", @@ -14301,6 +14381,31 @@ "@octokit/types": "^13.8.0" } }, + "@octokit/plugin-retry": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/@octokit/plugin-retry/-/plugin-retry-7.2.1.tgz", + "integrity": "sha512-wUc3gv0D6vNHpGxSaR3FlqJpTXGWgqmk607N9L3LvPL4QjaxDgX/1nY2mGpT37Khn+nlIXdljczkRnNdTTV3/A==", + "requires": { + "@octokit/request-error": "^6.1.8", + "@octokit/types": "^14.0.0", + "bottleneck": "^2.15.3" + }, + "dependencies": { + "@octokit/openapi-types": { + "version": "25.1.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-25.1.0.tgz", + "integrity": "sha512-idsIggNXUKkk0+BExUn1dQ92sfysJrje03Q0bv0e+KPLrvyqZF8MnBpFz8UNfYDwB3Ie7Z0TByjWfzxt7vseaA==" + }, + "@octokit/types": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-14.1.0.tgz", + "integrity": "sha512-1y6DgTy8Jomcpu33N+p5w58l6xyt55Ar2I91RPiIA0xCJBXyUAhXCcmZaDWSANiha7R9a6qJJ2CRomGPZ6f46g==", + "requires": { + "@octokit/openapi-types": "^25.1.0" + } + } + } + }, "@octokit/plugin-throttling": { "version": "9.4.0", "resolved": "https://registry.npmjs.org/@octokit/plugin-throttling/-/plugin-throttling-9.4.0.tgz", @@ -14323,11 +14428,26 @@ } }, "@octokit/request-error": { - "version": "6.1.7", - "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-6.1.7.tgz", - "integrity": "sha512-69NIppAwaauwZv6aOzb+VVLwt+0havz9GT5YplkeJv7fG7a40qpLt/yZKyiDxAhgz0EtgNdNcb96Z0u+Zyuy2g==", + "version": "6.1.8", + "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-6.1.8.tgz", + "integrity": "sha512-WEi/R0Jmq+IJKydWlKDmryPcmdYSVjL3ekaiEL1L9eo1sUnqMJ+grqmC9cjk7CA7+b2/T397tO5d8YLOH3qYpQ==", "requires": { - "@octokit/types": "^13.6.2" + "@octokit/types": "^14.0.0" + }, + "dependencies": { + "@octokit/openapi-types": { + "version": "25.1.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-25.1.0.tgz", + "integrity": "sha512-idsIggNXUKkk0+BExUn1dQ92sfysJrje03Q0bv0e+KPLrvyqZF8MnBpFz8UNfYDwB3Ie7Z0TByjWfzxt7vseaA==" + }, + "@octokit/types": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-14.1.0.tgz", + "integrity": "sha512-1y6DgTy8Jomcpu33N+p5w58l6xyt55Ar2I91RPiIA0xCJBXyUAhXCcmZaDWSANiha7R9a6qJJ2CRomGPZ6f46g==", + "requires": { + "@octokit/openapi-types": "^25.1.0" + } + } } }, "@octokit/rest": { @@ -14733,6 +14853,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.43.tgz", "integrity": "sha512-8Q+LNpdxf057brvPu1lMtC5Vn7J119xrP1aq4qiaefNioQUYANF/CYeK4NsKorSZyUGJ66g0IM+4bbjwx45o2A==", "devOptional": true, + "peer": true, "requires": { "@types/prop-types": "*", "@types/scheduler": "*", @@ -14850,6 +14971,7 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.18.0.tgz", "integrity": "sha512-+08nYfurBzSSPndngnHvFw/fniWYJ5ymOrn/63oMIbgomVQOvIDhBoJmYZ9lwQOCnQV9xHGvf88ze3jFGUYooQ==", "dev": true, + "peer": true, "requires": { "@typescript-eslint/scope-manager": "5.18.0", "@typescript-eslint/types": "5.18.0", @@ -15126,7 +15248,8 @@ "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", - "dev": true + "dev": true, + "peer": true }, "acorn-import-phases": { "version": "1.0.4", @@ -15157,6 +15280,7 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, + "peer": true, "requires": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -15525,6 +15649,7 @@ "version": "4.28.1", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "peer": true, "requires": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -16360,6 +16485,7 @@ "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.12.0.tgz", "integrity": "sha512-it1oBL9alZg1S8UycLm5YDMAkIhtH6FtAzuZs6YvoGVldWjbS08BkAdb/ymP9LlAyq8koANu32U7Ib/w+UNh8Q==", "dev": true, + "peer": true, "requires": { "@eslint/eslintrc": "^1.2.1", "@humanwhocodes/config-array": "^0.9.2", @@ -17055,6 +17181,7 @@ "version": "4.1.17", "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-4.1.17.tgz", "integrity": "sha512-thx1wvKzblzbs0XaK2X0G1JuwIdARcoNOW7VVwjO8BUltzXPyONGAElLu6CiCScsOQRI7FIk/45YTFtJw5Yozw==", + "peer": true, "requires": { "@emotion/is-prop-valid": "^0.8.2", "framesync": "5.3.0", @@ -18907,6 +19034,7 @@ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==", "dev": true, + "peer": true, "requires": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -19098,6 +19226,7 @@ "version": "17.0.2", "resolved": "https://registry.npmjs.org/react/-/react-17.0.2.tgz", "integrity": "sha512-gnhPt75i/dq/z3/6q/0asP78D0u592D5L1pd7M8P+dck6Fu/jJeL6iVVK23fptSUZj8Vjf++7wXA8UNclGQcbA==", + "peer": true, "requires": { "loose-envify": "^1.1.0", "object-assign": "^4.1.1" @@ -19115,6 +19244,7 @@ "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-17.0.2.tgz", "integrity": "sha512-s4h96KtLDUQlsENhMn1ar8t2bEa+q/YAtj8pPPdIjPDGBDIVNsrD9aXNWqspUe6AzKCIG0C1HZZLqLV7qpOBGA==", + "peer": true, "requires": { "loose-envify": "^1.1.0", "object-assign": "^4.1.1", @@ -20114,6 +20244,7 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, + "peer": true, "requires": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -20304,7 +20435,8 @@ "tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "peer": true }, "tsutils": { "version": "3.21.0", @@ -20368,7 +20500,8 @@ "version": "4.6.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.6.3.tgz", "integrity": "sha512-yNIatDa5iaofVozS/uQJEl3JRWLKKGJKh6Yaiv0GLGSuhpFJe7P3SbHZ8/yjAHRQwKRoA6YZqlfjXWmVzoVSMw==", - "dev": true + "dev": true, + "peer": true }, "uid-safe": { "version": "2.1.5", @@ -20544,6 +20677,7 @@ "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.105.0.tgz", "integrity": "sha512-gX/dMkRQc7QOMzgTe6KsYFM7DxeIONQSui1s0n/0xht36HvrgbxtM1xBlgx596NbpHuQU8P7QpKwrZYwUX48nw==", "dev": true, + "peer": true, "requires": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", @@ -20577,6 +20711,7 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, + "peer": true, "requires": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -20618,6 +20753,7 @@ "resolved": "https://registry.npmjs.org/webpack-cli/-/webpack-cli-6.0.1.tgz", "integrity": "sha512-MfwFQ6SfwinsUVi0rNJm7rHZ31GyTcpVE5pgVA3hwFRb7COD4TzjUUwhGWKfO50+xdc2MQPuEBBJoqIMGt3JDw==", "dev": true, + "peer": true, "requires": { "@discoveryjs/json-ext": "^0.6.1", "@webpack-cli/configtest": "^3.0.1", @@ -20661,6 +20797,7 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, + "peer": true, "requires": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -20738,6 +20875,7 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, + "peer": true, "requires": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", diff --git a/package.json b/package.json index 80747557..e18c2d15 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "@fortawesome/fontawesome-svg-core": "^6.1.1", "@fortawesome/free-solid-svg-icons": "^6.0.0", "@fortawesome/react-fontawesome": "^0.1.17", + "@octokit/plugin-retry": "^7.2.1", "@octokit/plugin-throttling": "^9.4.0", "@octokit/rest": "^21.1.0", "@types/underscore": "^1.10.24", @@ -66,7 +67,7 @@ "frontend:watch": "webpack --watch --config ./frontend/webpack.dev.config.js", "postinstall": "npm run build", "build": "webpack --config ./frontend/webpack.prod.config.js", - "test": "node --test test/*.test.js", + "test": "CONFIG_PATH=../test/fixtures/config.js node --test test/*.test.js", "precommit": "lint-staged" }, "lint-staged": { diff --git a/test/fixtures/config.js b/test/fixtures/config.js new file mode 100644 index 00000000..1e7b4365 --- /dev/null +++ b/test/fixtures/config.js @@ -0,0 +1,66 @@ +// Minimal ESM config used only by the test suite (CONFIG_PATH points here). +// Dummy values — nothing here talks to GitHub or MySQL; the tests stub those +// collaborators. It exists to satisfy module-load-time reads across the import +// graph (config-loader repos, git-manager github.token, db.js mysql.*, and the +// regex tables some models compile on import). +const emoji = + "(©|®|[ -㌀]|\ud83c[퀀-\udfff]|\ud83d[퀀-\udfff]|\ud83e[퀀-\udfff])"; +const signature = "(:[^\n:]+:|" + emoji + ")"; + +export default { + title: "Pulldasher (test)", + port: 3000, + github: { + clientId: "test-client-id", + secret: "test-secret", + callbackURL: "http://localhost:3000/auth/github/callback", + token: "test-token", + hook_secret: "test-hook-secret", + }, + session: { secret: "test-session-secret" }, + + repos: ["test/repo-a", "test/repo-b", "test/repo-c"], + + mysql: { + host: "localhost", + db: "pulldasher_test", + user: "test", + pass: "test", + }, + + body_tags: [ + { name: "cr_req", regex: /\bcr_req ([0-9]+)\b/i, default: 2 }, + { name: "qa_req", regex: /\bqa_req ([0-9]+)\b/i, default: 1 }, + { + name: "closes", + regex: /\b(?:close(?:s|d)?|fix(?:es|ed)?|resolve(?:s|d)?) #([0-9]+)\b/i, + default: null, + }, + { + name: "connects", + regex: /\b(?:connect(?:s|ed)? to|connects) #([0-9]+)\b/i, + default: null, + }, + ], + tags: [ + { name: "dev_block", regex: new RegExp("\\bdev_block " + signature, "i") }, + { name: "un_dev_block", regex: new RegExp("\\bun_dev_block " + signature, "i") }, + { name: "deploy_block", regex: new RegExp("\\bdeploy_block " + signature, "i") }, + { name: "un_deploy_block", regex: new RegExp("\\bun_deploy_block " + signature, "i") }, + { name: "QA", regex: new RegExp("\\bQA " + signature, "i") }, + { name: "CR", regex: new RegExp("\\bCR " + signature, "i") }, + ], + labels: [ + { + name: "difficulty", + regex: /^size: [0-9]+$/, + process: function (label) { + const match = label ? label.match(/[0-9]+/) : null; + return match ? parseInt(match[0], 10) : null; + }, + }, + ], + + unauthenticated_timeout: 10 * 1000, + token_timeout: 100 * 1000, +}; diff --git a/test/forEachRepo.test.js b/test/forEachRepo.test.js new file mode 100644 index 00000000..30f215cd --- /dev/null +++ b/test/forEachRepo.test.js @@ -0,0 +1,29 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import utils from "../lib/utils.js"; + +// Fixture config (test/fixtures/config.js) lists three repos. +test("forEachRepo isolates a failing repo and reports it in failedRepos", async () => { + const failing = "test/repo-b"; + const lambda = (repo) => + repo === failing + ? Promise.reject(new Error("transient 502")) + : Promise.resolve([{ repo }]); + + const { items, failedRepos } = await utils.forEachRepo(lambda); + + assert.deepEqual(failedRepos, [failing]); + assert.deepEqual( + items.map((i) => i.repo).sort(), + ["test/repo-a", "test/repo-c"] + ); +}); + +test("forEachRepo returns empty failedRepos when every repo succeeds", async () => { + const lambda = (repo) => Promise.resolve([{ repo }]); + + const { items, failedRepos } = await utils.forEachRepo(lambda); + + assert.deepEqual(failedRepos, []); + assert.equal(items.length, 3); +}); diff --git a/test/refresh-queue.test.js b/test/refresh-queue.test.js new file mode 100644 index 00000000..962363b4 --- /dev/null +++ b/test/refresh-queue.test.js @@ -0,0 +1,47 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { processIssueItem, processPullItem } from "../lib/refresh.js"; + +// A transient failure parsing one item must not abort the sweep: the handler +// logs and still calls next() so the queue keeps draining. +test("processIssueItem continues past a failing parse", async () => { + let nextCalled = 0; + const deps = { + parseIssue: () => Promise.reject(new Error("transient 500")), + updateAllIssueData: () => { + throw new Error("updateAllIssueData should not run after a parse failure"); + }, + }; + + await processIssueItem({ number: 7, repo: "test/repo-a" }, () => nextCalled++, deps); + + assert.equal(nextCalled, 1); +}); + +test("processPullItem continues past a failing parse", async () => { + let nextCalled = 0; + const deps = { + parse: () => Promise.reject(new Error("transient 502")), + updateAllPullData: () => { + throw new Error("updateAllPullData should not run after a parse failure"); + }, + }; + + await processPullItem( + { number: 9, base: { repo: { full_name: "test/repo-a" } } }, + () => nextCalled++, + deps + ); + + assert.equal(nextCalled, 1); +}); + +test("processIssueItem runs a queued sentinel function and calls next", async () => { + let sentinelCalled = 0; + let nextCalled = 0; + + await processIssueItem(() => sentinelCalled++, () => nextCalled++, {}); + + assert.equal(sentinelCalled, 1); + assert.equal(nextCalled, 1); +}); From 4024a81b4027c94aa208484b9ac721797789d509 Mon Sep 17 00:00:00 2001 From: Shawn Tice Date: Wed, 17 Jun 2026 16:07:05 -0500 Subject: [PATCH 2/6] Let refresh-* bins target specific repos MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bulk refresh bins always swept every configured repo. For manual QA you want to exercise one repo, and for recovery you want to re-run just the repos a sweep reported as skipped — without a 25-minute full pass. Each bulk bin now takes optional repo-name args: node bin/refresh-all-issues iFixit/ifixit No args = all configured repos (unchanged). Names must be configured; an unknown name prints a usage line and exits non-zero so a typo can't silently no-op a targeted run. Mechanics: utils.selectRepos(names) resolves names to their config.repos entries (preserving per-repo config), forEachRepo takes an optional repo subset, the four bulk refresh methods forward it, and a shared select-repos-arg helper parses argv for the bins. Co-Authored-By: Claude Opus 4.8 (1M context) --- bin/refresh-all-issues | 3 ++- bin/refresh-all-pulls | 3 ++- bin/refresh-open-issues | 3 ++- bin/refresh-open-pulls | 3 ++- lib/refresh.js | 16 +++++++------- lib/select-repos-arg.js | 18 +++++++++++++++ lib/utils.js | 46 +++++++++++++++++++++++++-------------- test/forEachRepo.test.js | 15 +++++++++++++ test/select-repos.test.js | 25 +++++++++++++++++++++ 9 files changed, 104 insertions(+), 28 deletions(-) create mode 100644 lib/select-repos-arg.js create mode 100644 test/select-repos.test.js diff --git a/bin/refresh-all-issues b/bin/refresh-all-issues index 3b1eac21..6eb244ff 100755 --- a/bin/refresh-all-issues +++ b/bin/refresh-all-issues @@ -1,11 +1,12 @@ #!/usr/bin/env node import { default as debugInit } from '../lib/debug.js'; import refresh, { reportFailedRepos } from '../lib/refresh.js'; +import selectReposArg from '../lib/select-repos-arg.js'; import db from '../lib/db.js'; debugInit('pulldasher:refresh*'); -refresh.allIssues() +refresh.allIssues(selectReposArg()) .done(function (result) { reportFailedRepos(result); db.end(); diff --git a/bin/refresh-all-pulls b/bin/refresh-all-pulls index 50e7a1b3..fc610fd5 100755 --- a/bin/refresh-all-pulls +++ b/bin/refresh-all-pulls @@ -1,11 +1,12 @@ #!/usr/bin/env node import { default as debugInit } from '../lib/debug.js'; import refresh, { reportFailedRepos } from '../lib/refresh.js'; +import selectReposArg from '../lib/select-repos-arg.js'; import db from '../lib/db.js'; debugInit('pulldasher:refresh*'); -refresh.allPulls() +refresh.allPulls(selectReposArg()) .done(function (result) { reportFailedRepos(result); db.end(); diff --git a/bin/refresh-open-issues b/bin/refresh-open-issues index 1668fe9f..934424ea 100755 --- a/bin/refresh-open-issues +++ b/bin/refresh-open-issues @@ -1,11 +1,12 @@ #!/usr/bin/env node import { default as debugInit } from '../lib/debug.js'; import refresh, { reportFailedRepos } from '../lib/refresh.js'; +import selectReposArg from '../lib/select-repos-arg.js'; import db from '../lib/db.js'; debugInit('pulldasher:refresh*'); -refresh.openIssues() +refresh.openIssues(selectReposArg()) .done(function (result) { reportFailedRepos(result); db.end(); diff --git a/bin/refresh-open-pulls b/bin/refresh-open-pulls index e258f606..fc79307b 100755 --- a/bin/refresh-open-pulls +++ b/bin/refresh-open-pulls @@ -1,11 +1,12 @@ #!/usr/bin/env node import { default as debugInit } from '../lib/debug.js'; import refresh, { reportFailedRepos } from '../lib/refresh.js'; +import selectReposArg from '../lib/select-repos-arg.js'; import db from '../lib/db.js'; debugInit('pulldasher:refresh*'); -refresh.openPulls() +refresh.openPulls(selectReposArg()) .done(function (result) { reportFailedRepos(result); db.end(); diff --git a/lib/refresh.js b/lib/refresh.js index 3dcef2d8..021b751e 100644 --- a/lib/refresh.js +++ b/lib/refresh.js @@ -19,17 +19,17 @@ export default { return gitManager.getIssue(repo, number).then(pushOnQueue(issueQueue)); }, - allIssues: function refreshOpenIssues() { + allIssues: function refreshAllIssues(repos) { refreshDebug("refresh all issues"); return utils - .forEachRepo(gitManager.getAllIssues) + .forEachRepo(gitManager.getAllIssues, { repos: repos }) .then(drainThrough(issueQueue)); }, - openIssues: function refreshOpenIssues() { + openIssues: function refreshOpenIssues(repos) { refreshDebug("refresh all open issues"); return utils - .forEachRepo(gitManager.getOpenIssues) + .forEachRepo(gitManager.getOpenIssues, { repos: repos }) .then(drainThrough(issueQueue)); }, @@ -40,17 +40,17 @@ export default { return gitManager.getPull(repo, number).then(pushOnQueue(pullQueue)); }, - allPulls: function refreshAllPulls() { + allPulls: function refreshAllPulls(repos) { refreshDebug("refresh all pull"); return utils - .forEachRepo(gitManager.getAllPulls) + .forEachRepo(gitManager.getAllPulls, { repos: repos }) .then(drainThrough(pullQueue)); }, - openPulls: function refreshOpenPulls() { + openPulls: function refreshOpenPulls(repos) { refreshDebug("refresh all open pulls"); return utils - .forEachRepo(gitManager.getOpenPulls) + .forEachRepo(gitManager.getOpenPulls, { repos: repos }) .then(drainThrough(pullQueue)); }, }; diff --git a/lib/select-repos-arg.js b/lib/select-repos-arg.js new file mode 100644 index 00000000..200b3b65 --- /dev/null +++ b/lib/select-repos-arg.js @@ -0,0 +1,18 @@ +import path from "path"; +import utils from "./utils.js"; + +/** + * Resolve repo-name CLI args (after the script name) to config repo objects for + * the bulk refresh bins. No args → all configured repos. An unconfigured name + * prints a usage line and exits non-zero, so a typo can't silently no-op a + * targeted QA / recovery run. + */ +export default function selectReposArg() { + try { + return utils.selectRepos(process.argv.slice(2)); + } catch (err) { + console.error(err.message); + console.error("Usage: %s [owner/repo ...]", path.basename(process.argv[1])); + process.exit(2); + } +} diff --git a/lib/utils.js b/lib/utils.js index 4d1920e5..5ddc403e 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -45,11 +45,30 @@ export default { }, /** - * Provide a function that returns an array of values for a given repo. - * The second argument should be all arguments after the repository, since the - * repository argument will be dealt with by this function. - * - * The function should take a repository name, and return an array of values. + * Resolve a list of repo names to their `config.repos` entries (preserving + * each repo's config, e.g. requiredStatuses). Empty/omitted names → all + * configured repos. Throws on a name that isn't configured, since pulldasher + * only tracks configured repos. + */ + selectRepos: function (names) { + if (!names || !names.length) { + return config.repos; + } + const byName = _.indexBy(config.repos, 'name'); + const unknown = _.difference(names, _.keys(byName)); + if (unknown.length) { + throw new Error('Repos not configured: ' + unknown.join(', ')); + } + return names.map(function (name) { + return byName[name]; + }); + }, + + /** + * Run `singleRepoLambda(repoName)` for each repo and collect the results. + * `repos` is the list of repos to run over (defaults to all configured repos + * — pass a subset from selectRepos to target specific repos for manual QA or + * failure recovery). * * Resolves to `{ items, failedRepos }`: the flattened values from the repos * that succeeded, plus the names of any that rejected. We use allSettled @@ -57,23 +76,18 @@ export default { * a paginated list) doesn't abort the whole sweep — the caller decides how to * report `failedRepos`. */ - forEachRepo: function (singleRepoLambda, args) { - // default value - args = args || []; - args.unshift(null); - var repos = config.repos; - var promises = repos.map(function (currentRepo) { - args[0] = currentRepo.name; - return singleRepoLambda.apply(this, args); + forEachRepo: function (singleRepoLambda, { repos = config.repos } = {}) { + const promises = repos.map(function (currentRepo) { + return singleRepoLambda(currentRepo.name); }); return Promise.allSettled(promises).then(function (results) { - var repoItems = []; - var failedRepos = []; + const repoItems = []; + const failedRepos = []; results.forEach(function (result, i) { if (result.status === 'fulfilled') { repoItems.push(result.value); } else { - var repoName = repos[i].name; + const repoName = repos[i].name; failedRepos.push(repoName); console.error( 'forEachRepo: failed to fetch repo %s: %s', diff --git a/test/forEachRepo.test.js b/test/forEachRepo.test.js index 30f215cd..13ccd185 100644 --- a/test/forEachRepo.test.js +++ b/test/forEachRepo.test.js @@ -27,3 +27,18 @@ test("forEachRepo returns empty failedRepos when every repo succeeds", async () assert.deepEqual(failedRepos, []); assert.equal(items.length, 3); }); + +test("forEachRepo restricts to an explicit repo subset", async () => { + const seen = []; + const lambda = (repo) => { + seen.push(repo); + return Promise.resolve([{ repo }]); + }; + + const subset = utils.selectRepos(["test/repo-a", "test/repo-c"]); + const { items, failedRepos } = await utils.forEachRepo(lambda, { repos: subset }); + + assert.deepEqual(seen.sort(), ["test/repo-a", "test/repo-c"]); + assert.deepEqual(failedRepos, []); + assert.equal(items.length, 2); +}); diff --git a/test/select-repos.test.js b/test/select-repos.test.js new file mode 100644 index 00000000..56eec3a9 --- /dev/null +++ b/test/select-repos.test.js @@ -0,0 +1,25 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import utils from "../lib/utils.js"; + +// Fixture config (test/fixtures/config.js) lists three repos. +test("selectRepos returns every configured repo when names are empty", () => { + assert.equal(utils.selectRepos([]).length, 3); + assert.equal(utils.selectRepos().length, 3); +}); + +test("selectRepos resolves names to their config repo objects", () => { + const repos = utils.selectRepos(["test/repo-b"]); + assert.equal(repos.length, 1); + assert.equal(repos[0].name, "test/repo-b"); +}); + +test("selectRepos throws listing every unconfigured repo", () => { + assert.throws( + () => utils.selectRepos(["test/repo-a", "test/nope-1", "test/nope-2"]), + (err) => /not configured/i.test(err.message) && + err.message.includes("test/nope-1") && + err.message.includes("test/nope-2") && + !err.message.includes("test/repo-a") + ); +}); From 7a80aaf3b42d80f5498430fdab87d6006df0e195 Mon Sep 17 00:00:00 2001 From: Shawn Tice Date: Wed, 17 Jun 2026 16:23:33 -0500 Subject: [PATCH 3/6] Add db.end() so refresh bins exit cleanly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every refresh-* bin ends its run with db.end(), but lib/db.js's default export only had query() — so a finished backfill always threw "TypeError: db.end is not a function" and exited non-zero, even on a fully clean sweep. That also defeated the new per-repo exit-code signal (a clean run could never reach exit 0). Add end() to close the pool. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/db.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/lib/db.js b/lib/db.js index 36a5d47a..7a103482 100644 --- a/lib/db.js +++ b/lib/db.js @@ -24,5 +24,10 @@ export default { query: function (query, params) { return pool.query(query, params) .then((result) => result[0]); // [rows, fields] + }, + // The refresh-* bins call this to close the pool so the process can exit + // cleanly once a backfill finishes. + end: function () { + return pool.end(); } }; From 04c07cce35fda1eea98bf78ab2541eed396ecac6 Mon Sep 17 00:00:00 2001 From: Shawn Tice Date: Wed, 17 Jun 2026 18:49:39 -0500 Subject: [PATCH 4/6] Fix dead debug() calls in the webhook controller MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit githubHooks imported the `debug` factory and then called it directly (debug("Received GitHub webhook…")), which just mints a logger named after the message and emits nothing — so webhook receipt, action, and label events never logged. Create a `pulldasher:hooks` logger once and use it, matching the other modules (git-manager, refresh). Co-Authored-By: Claude Opus 4.8 (1M context) --- controllers/githubHooks.js | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/controllers/githubHooks.js b/controllers/githubHooks.js index 7ad36334..fbd2b3d1 100644 --- a/controllers/githubHooks.js +++ b/controllers/githubHooks.js @@ -12,6 +12,8 @@ import getLogin from "../lib/get-user-login.js"; import utils from "../lib/utils.js"; import dbManager from "../lib/db-manager.js"; +const hooksDebug = debug("pulldasher:hooks"); + const HooksController = { main: function (req, res) { // Variable for promise that will resolve when the hook is known to have @@ -22,7 +24,7 @@ const HooksController = { var secret = req.query.secret; if (secret !== config.github.hook_secret) { var m = "Invalid Hook Secret: " + secret; - debug(m); + hooksDebug(m); console.error(m); return res.status(401).send("Invalid POST"); } @@ -42,7 +44,7 @@ const HooksController = { } var event = req.get("X-GitHub-Event"); - debug("Received GitHub webhook, Event: %s, Action: %s", event, body.action); + hooksDebug("Received GitHub webhook, Event: %s, Action: %s", event, body.action); if (event === "status") { const updatedStatus = new Status({ @@ -190,7 +192,7 @@ const HooksController = { }; function handleIssueEvent(body) { - debug("Webhook action: %s for issue #%s", body.action, body.issue.number); + hooksDebug("Webhook action: %s for issue #%s", body.action, body.issue.number); var doneHandling = handleLabelEvents(body); @@ -217,7 +219,7 @@ function handleLabelEvents(body) { var object = body.pull_request || body.issue; switch (body.action) { case "labeled": - debug("Added label: %s", body.label.name); + hooksDebug("Added label: %s", body.label.name); return dbManager.insertLabel( new Label( body.label, @@ -229,7 +231,7 @@ function handleLabelEvents(body) { ); case "unlabeled": - debug("Removed label: %s", body.label.name); + hooksDebug("Removed label: %s", body.label.name); return dbManager.deleteLabel( new Label(body.label, object.number, body.repository.full_name) ); From 5f336bf6b0276ed5d7613882de527ebe82b2464b Mon Sep 17 00:00:00 2001 From: Shawn Tice Date: Thu, 18 Jun 2026 14:04:06 -0500 Subject: [PATCH 5/6] Verify + fix the transient-retry path; log retries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Writing a test for the plugin-retry wiring surfaced a real bug: the retry count was set under `request: { retries: 5 }`, which — per plugin-retry's documented behavior — forces retries on every status, bypassing the doNotRetry (4xx) list. So 404s/422s/401s would each be retried 5 times. Move the count to `retry: { retries: 5 }`, which bumps the count while still honoring doNotRetry. Also add a pulldasher:github log on transient (5xx/network) request errors: plugin-retry retries silently, so without this a real blip during a long backfill leaves no trace. Rethrows untouched, so the retry logic is unaffected; rate-limit 4xx stays with the throttle plugin. test/retry-plugin.test.js proves all three: 503 retried then succeeds, persistent 5xx gives up after the configured retries, and a 404 is not retried. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/git-manager.js | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/lib/git-manager.js b/lib/git-manager.js index 7bf47bda..99d61223 100644 --- a/lib/git-manager.js +++ b/lib/git-manager.js @@ -24,8 +24,10 @@ const github = new MyOctokit({ auth: config.github.token, // Auto-retry transient 5xx/network failures (the throttle plugin only covers // rate limits); the long bulk backfills make thousands of calls, so a single - // transient error shouldn't abort the sweep before it can be reissued. - request: { retries: 5 }, + // transient error shouldn't abort the sweep before it can be reissued. Note: + // the count goes under `retry`, not `request` — `request: { retries }` forces + // retries on every status, bypassing plugin-retry's doNotRetry (4xx) list. + retry: { retries: 5 }, throttle: { onRateLimit: (retryAfter, options) => { github.log.warn( @@ -53,6 +55,25 @@ const github = new MyOctokit({ }, }); +// plugin-retry retries transient 5xx/network errors silently (no log of its +// own), so a real blip during a long backfill would otherwise leave no trace. +// Surface those attempts under pulldasher:github. We only log transient errors +// (5xx / network) — the throttle plugin already logs 4xx rate limits — and +// rethrow untouched so plugin-retry's retry logic is unaffected. +github.hook.error("request", (error, options) => { + const status = error.status; + if (status === undefined || status >= 500) { + gitDebug( + "Transient request error on %s %s (status %s), attempt %s", + options.method, + options.url, + status === undefined ? "network" : status, + (options.request.retryCount || 0) + 1 + ); + } + throw error; +}); + const githubRest = github.rest; export default { From bec6bc14bff4c83262f8a58ef0022eeadd1c8adb Mon Sep 17 00:00:00 2001 From: Shawn Tice Date: Thu, 18 Jun 2026 17:40:59 -0500 Subject: [PATCH 6/6] Re-report per-item failures at end of run, exit non-zero The bulk bins already re-reported repos that couldn't be fetched and exited non-zero, but a per-issue/per-pull refresh failure was only logged inline mid-run (buried under thousands of debug lines) and didn't affect the exit code, so a sweep that dropped individual issues still looked clean and exited 0. Collect per-item failures during a bulk run and re-report them, alongside the skipped repos, at the end of the run, exiting non-zero if anything failed. Collection is gated to bulk runs (collectingBulkFailures) so the live server's webhook-driven refreshes don't accumulate. reportFailedRepos becomes reportFailures. Co-Authored-By: Claude Opus 4.8 (1M context) --- bin/refresh-all-issues | 4 +- bin/refresh-all-pulls | 4 +- bin/refresh-open-issues | 4 +- bin/refresh-open-pulls | 4 +- lib/refresh.js | 148 +++++++++++++++++++++++------------ test/refresh-queue.test.js | 23 +++--- test/report-failures.test.js | 31 ++++++++ 7 files changed, 145 insertions(+), 73 deletions(-) create mode 100644 test/report-failures.test.js diff --git a/bin/refresh-all-issues b/bin/refresh-all-issues index 6eb244ff..7123fa26 100755 --- a/bin/refresh-all-issues +++ b/bin/refresh-all-issues @@ -1,6 +1,6 @@ #!/usr/bin/env node import { default as debugInit } from '../lib/debug.js'; -import refresh, { reportFailedRepos } from '../lib/refresh.js'; +import refresh, { reportFailures } from '../lib/refresh.js'; import selectReposArg from '../lib/select-repos-arg.js'; import db from '../lib/db.js'; @@ -8,6 +8,6 @@ debugInit('pulldasher:refresh*'); refresh.allIssues(selectReposArg()) .done(function (result) { - reportFailedRepos(result); + reportFailures(result); db.end(); }); diff --git a/bin/refresh-all-pulls b/bin/refresh-all-pulls index fc610fd5..ebaa025e 100755 --- a/bin/refresh-all-pulls +++ b/bin/refresh-all-pulls @@ -1,6 +1,6 @@ #!/usr/bin/env node import { default as debugInit } from '../lib/debug.js'; -import refresh, { reportFailedRepos } from '../lib/refresh.js'; +import refresh, { reportFailures } from '../lib/refresh.js'; import selectReposArg from '../lib/select-repos-arg.js'; import db from '../lib/db.js'; @@ -8,6 +8,6 @@ debugInit('pulldasher:refresh*'); refresh.allPulls(selectReposArg()) .done(function (result) { - reportFailedRepos(result); + reportFailures(result); db.end(); }); diff --git a/bin/refresh-open-issues b/bin/refresh-open-issues index 934424ea..86f56d4a 100755 --- a/bin/refresh-open-issues +++ b/bin/refresh-open-issues @@ -1,6 +1,6 @@ #!/usr/bin/env node import { default as debugInit } from '../lib/debug.js'; -import refresh, { reportFailedRepos } from '../lib/refresh.js'; +import refresh, { reportFailures } from '../lib/refresh.js'; import selectReposArg from '../lib/select-repos-arg.js'; import db from '../lib/db.js'; @@ -8,6 +8,6 @@ debugInit('pulldasher:refresh*'); refresh.openIssues(selectReposArg()) .done(function (result) { - reportFailedRepos(result); + reportFailures(result); db.end(); }); diff --git a/bin/refresh-open-pulls b/bin/refresh-open-pulls index fc79307b..fef36e78 100755 --- a/bin/refresh-open-pulls +++ b/bin/refresh-open-pulls @@ -1,6 +1,6 @@ #!/usr/bin/env node import { default as debugInit } from '../lib/debug.js'; -import refresh, { reportFailedRepos } from '../lib/refresh.js'; +import refresh, { reportFailures } from '../lib/refresh.js'; import selectReposArg from '../lib/select-repos-arg.js'; import db from '../lib/db.js'; @@ -8,6 +8,6 @@ debugInit('pulldasher:refresh*'); refresh.openPulls(selectReposArg()) .done(function (result) { - reportFailedRepos(result); + reportFailures(result); db.end(); }); diff --git a/lib/refresh.js b/lib/refresh.js index 021b751e..16b64b90 100644 --- a/lib/refresh.js +++ b/lib/refresh.js @@ -59,10 +59,13 @@ export default { * Returns a function that will: * push its first argument to the specified Queue * and return a promise that is fulfilled when the item is fully processed. + * + * Single-item refreshes (webhooks, socket) have no end-of-run report, so no + * failure collector is attached. */ function pushOnQueue(queue) { return function (githubResponse) { - queue.push(githubResponse); + queue.push({ response: githubResponse, onFailure: null }); return new Promise(function (resolve) { queue.push(resolve); }); @@ -71,15 +74,19 @@ function pushOnQueue(queue) { /** * Returns a function that will: - * push all the entries in the array (first argument) to the sppecified + * push all the entries in the array (first argument) to the specified * Queue and return a promise that is fulfilled when the items are fully * processed. + * + * Each item carries the run's `onFailure` collector (null for single-item + * webhook/socket refreshes), so a failed parse is recorded for that run's + * end-of-run report. */ -function pushAllOnQueue(queue) { +function pushAllOnQueue(queue, onFailure) { return function (githubResponses) { return new Promise(function (resolve) { githubResponses.forEach(function (githubResponse) { - queue.push(githubResponse); + queue.push({ response: githubResponse, onFailure: onFailure }); }); queue.push(resolve); }); @@ -87,108 +94,145 @@ function pushAllOnQueue(queue) { } /** - * For the bulk bin scripts: log any repos that couldn't be fetched and flag a - * non-zero exit, so a partial backfill (e.g. one repo down on transient errors) - * isn't mistaken for a complete one. + * For the bulk bin scripts: at the end of a run, re-report everything that + * failed — repos that couldn't be fetched, plus individual issues/pulls that + * couldn't be refreshed — and flag a non-zero exit, so a partial backfill isn't + * mistaken for a complete one. The per-failure lines were already logged inline + * mid-run; repeating them at the end (one per line, greppable) keeps them from + * getting buried under thousands of debug lines. */ -export function reportFailedRepos({ failedRepos }) { - if (failedRepos.length) { - // One repo per line so the skipped set is greppable in the container logs. - failedRepos.forEach(function (repo) { - console.error("Skipped repo (could not fetch): %s", repo); - }); +export function reportFailures({ failedRepos, failedItems }) { + failedItems = failedItems || []; + failedRepos.forEach(function (repo) { + console.error("Skipped repo (could not fetch): %s", repo); + }); + failedItems.forEach(function (item) { + console.error("Failed to refresh %s #%s", item.repo, item.number); + }); + if (failedRepos.length || failedItems.length) { + console.error( + "Backfill incomplete: %s repo(s) skipped, %s item(s) failed (listed above).", + failedRepos.length, + failedItems.length + ); process.exitCode = 1; } } /** * Bridges a forEachRepo result onto a queue: pushes the fetched items, waits - * for them to drain, then resolves to `{ failedRepos }` so the bulk bin scripts - * can report (and exit non-zero on) repos that couldn't be fetched. + * for them to drain, then resolves to `{ failedRepos, failedItems }` so the + * bulk bin scripts can report (and exit non-zero on) both repos that couldn't + * be fetched and items that couldn't be refreshed. `failedItems` is local to + * this drain, so the live server's webhook refreshes never accumulate here. */ function drainThrough(queue) { return function ({ items, failedRepos }) { - return pushAllOnQueue(queue)(items).then(function () { - return { failedRepos }; + const failedItems = []; + const onFailure = function (repo, number) { + failedItems.push({ repo: repo, number: number }); + }; + return pushAllOnQueue(queue, onFailure)(items).then(function () { + return { failedRepos: failedRepos, failedItems: failedItems }; }); }; } /** - * Process one item off the issue queue. Dependencies are injected so the - * failure path is testable. A transient parse/update error is logged and - * swallowed — we still call next() so one bad issue can't abort the sweep (or, - * at runtime, crash the server on a webhook-driven refresh). + * Refresh one issue. Collaborators (including the run's `onFailure` collector) + * are injected so the failure path is testable. A transient parse/update error + * is logged, recorded via `onFailure` (when present), and swallowed — we still + * call next() so one bad issue can't abort the sweep (or, at runtime, crash the + * server on a webhook refresh). */ -export function processIssueItem(githubIssue, next, { parseIssue, updateAllIssueData }) { - // Allow callers to push functions on the queue to signal when an item has - // made it through - if (typeof githubIssue === "function") { - githubIssue(); - return next(); - } - refreshDebug("refreshing issue %s", githubIssue.number); - return parseIssue(githubIssue) +export function processIssueItem( + response, + next, + { parseIssue, updateAllIssueData, onFailure } +) { + refreshDebug("refreshing issue %s", response.number); + return parseIssue(response) .then(updateAllIssueData) .then(function () { refreshDebug( "done refreshing issue %s in repo %s", - githubIssue.number, - githubIssue.repo + response.number, + response.repo ); next(); }) .catch(function (err) { console.error( "Failed to refresh issue %s in repo %s: %s", - githubIssue.number, - githubIssue.repo, + response.number, + response.repo, (err && err.message) || err ); + if (onFailure) { + onFailure(response.repo, response.number); + } next(); }); } /** - * Process one item off the pull queue. See processIssueItem. + * Refresh one pull. See processIssueItem. */ -export function processPullItem(githubPull, next, { parse, updateAllPullData }) { - // Allow callers to push functions on the queue to signal when an item has - // made it through - if (typeof githubPull === "function") { - githubPull(); - return next(); - } - var repo = - githubPull.base && githubPull.base.repo && githubPull.base.repo.full_name; - refreshDebug("refreshing pull %s in repo %s", githubPull.number, repo); - return parse(githubPull) +export function processPullItem( + response, + next, + { parse, updateAllPullData, onFailure } +) { + const repo = + response.base && response.base.repo && response.base.repo.full_name; + refreshDebug("refreshing pull %s in repo %s", response.number, repo); + return parse(response) .then(updateAllPullData) .then(function () { - refreshDebug("done refreshing pull %s", githubPull.number); + refreshDebug("done refreshing pull %s", response.number); next(); }) .catch(function (err) { console.error( "Failed to refresh pull %s in repo %s: %s", - githubPull.number, + response.number, repo, (err && err.message) || err ); + if (onFailure) { + onFailure(repo, response.number); + } next(); }); } -issueQueue.pop(function (githubIssue, next) { - processIssueItem(githubIssue, next, { +// The queue holds two kinds of entries: a batch-done sentinel (the bare resolve +// function pushed after a batch, see pushAllOnQueue) and work items +// `{ response, onFailure }`. onFailure rides on the item rather than on the +// queue or the fixed pop deps, so it stays scoped to the run that enqueued it — +// a bulk run collects its own failures, while webhook/socket refreshes (which +// push onFailure null) don't accumulate. Unwrap so the process functions see +// one response plus their injected collaborators. +issueQueue.pop(function (item, next) { + if (typeof item === "function") { + item(); + return next(); + } + processIssueItem(item.response, next, { parseIssue: gitManager.parseIssue, updateAllIssueData: dbManager.updateAllIssueData, + onFailure: item.onFailure, }); }); -pullQueue.pop(function (githubPull, next) { - processPullItem(githubPull, next, { +pullQueue.pop(function (item, next) { + if (typeof item === "function") { + item(); + return next(); + } + processPullItem(item.response, next, { parse: gitManager.parse, updateAllPullData: dbManager.updateAllPullData, + onFailure: item.onFailure, }); }); diff --git a/test/refresh-queue.test.js b/test/refresh-queue.test.js index 962363b4..a181c55d 100644 --- a/test/refresh-queue.test.js +++ b/test/refresh-queue.test.js @@ -3,28 +3,34 @@ import assert from "node:assert/strict"; import { processIssueItem, processPullItem } from "../lib/refresh.js"; // A transient failure parsing one item must not abort the sweep: the handler -// logs and still calls next() so the queue keeps draining. -test("processIssueItem continues past a failing parse", async () => { +// logs, records the failure via the injected onFailure, and still calls next() +// so the queue keeps draining. +test("processIssueItem records the failure and continues past a failing parse", async () => { let nextCalled = 0; + const failures = []; const deps = { parseIssue: () => Promise.reject(new Error("transient 500")), updateAllIssueData: () => { throw new Error("updateAllIssueData should not run after a parse failure"); }, + onFailure: (repo, number) => failures.push({ repo, number }), }; await processIssueItem({ number: 7, repo: "test/repo-a" }, () => nextCalled++, deps); assert.equal(nextCalled, 1); + assert.deepEqual(failures, [{ repo: "test/repo-a", number: 7 }]); }); -test("processPullItem continues past a failing parse", async () => { +test("processPullItem records the failure and continues past a failing parse", async () => { let nextCalled = 0; + const failures = []; const deps = { parse: () => Promise.reject(new Error("transient 502")), updateAllPullData: () => { throw new Error("updateAllPullData should not run after a parse failure"); }, + onFailure: (repo, number) => failures.push({ repo, number }), }; await processPullItem( @@ -34,14 +40,5 @@ test("processPullItem continues past a failing parse", async () => { ); assert.equal(nextCalled, 1); -}); - -test("processIssueItem runs a queued sentinel function and calls next", async () => { - let sentinelCalled = 0; - let nextCalled = 0; - - await processIssueItem(() => sentinelCalled++, () => nextCalled++, {}); - - assert.equal(sentinelCalled, 1); - assert.equal(nextCalled, 1); + assert.deepEqual(failures, [{ repo: "test/repo-a", number: 9 }]); }); diff --git a/test/report-failures.test.js b/test/report-failures.test.js new file mode 100644 index 00000000..472cd646 --- /dev/null +++ b/test/report-failures.test.js @@ -0,0 +1,31 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { reportFailures } from "../lib/refresh.js"; + +// reportFailures mutates process.exitCode, so save/restore around each case. +test("reportFailures flags a non-zero exit when repos or items failed", () => { + const prev = process.exitCode; + process.exitCode = 0; + reportFailures({ + failedRepos: ["owner/a"], + failedItems: [{ repo: "owner/b", number: 7 }], + }); + assert.equal(process.exitCode, 1); + process.exitCode = prev; +}); + +test("reportFailures leaves the exit code alone on a clean run", () => { + const prev = process.exitCode; + process.exitCode = 0; + reportFailures({ failedRepos: [], failedItems: [] }); + assert.equal(process.exitCode, 0); + process.exitCode = prev; +}); + +test("reportFailures tolerates a missing failedItems", () => { + const prev = process.exitCode; + process.exitCode = 0; + reportFailures({ failedRepos: [] }); + assert.equal(process.exitCode, 0); + process.exitCode = prev; +});