Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 38 additions & 16 deletions src/server/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,23 @@ const websocketProxyOption = {
changeOrigin: true,
}

function sendJson(res, statusCode, payload) {
res.statusCode = statusCode
res.setHeader('Content-Type', 'application/json')
res.end(JSON.stringify(payload))
}

function readJsonFileOr500(res, filePath, callback) {
jsonfile.readFile(filePath, (err, obj) => {
if (err) {
console.log('[ERROR]' + err)
sendJson(res, 500, { message: 'Failed to read server data' })
return
}
callback(obj)
})
}

Object.defineProperty(Array.prototype, 'flat', {
value: function (depth = 1) {
return this.reduce(function (flat, toFlatten) {
Expand Down Expand Up @@ -64,6 +81,12 @@ const refresh = spawn('node', ['refresh.js'], {
shell: true,
stdio: 'inherit',
})
refresh.on('exit', (code) => {
console.log(`[ERROR] refresh.js exited with code ${code}. Leaderboard data may become stale.`)
})
refresh.on('error', (err) => {
console.log(`[ERROR] Failed to start refresh.js: ${err.message}`)
})
process.on('exit', () => {
refresh.kill() // kill it when exit
})
Expand All @@ -75,15 +98,13 @@ const server = http
switch (route) {
case '/data':
res.setHeader('Cache-Control', 'no-store')
jsonfile.readFile(dataPath, (err, obj) => {
if (err) console.log('[ERROR]' + err)
readJsonFileOr500(res, dataPath, (obj) => {
res.end(JSON.stringify(obj))
})
break
case '/log':
res.setHeader('Cache-Control', 'no-store')
jsonfile.readFile(logPath, (err, obj) => {
if (err) console.log('[ERROR]' + err)
readJsonFileOr500(res, logPath, (obj) => {
res.end(JSON.stringify(obj))
})
break
Expand All @@ -107,7 +128,7 @@ const server = http
)
var contributorsList = []

Util.post(req, async (params) => {
Util.post(req, res, async (params) => {
const { token } = params
if (token === adminPassword) {
await Promise.all(
Expand Down Expand Up @@ -179,7 +200,7 @@ const server = http
return
}

Util.post(req, (params) => {
Util.post(req, res, (params) => {
const { token, includedRepositories } = params

if (token !== adminPassword) {
Expand All @@ -200,7 +221,7 @@ const server = http
res.end('Permission denied\n')
return
}
Util.post(req, (params) => {
Util.post(req, res, (params) => {
const { token, startDate } = params

if (token !== adminPassword) {
Expand All @@ -222,7 +243,7 @@ const server = http
return
}

Util.post(req, (params) => {
Util.post(req, res, (params) => {
const { token, interval } = params

if (token !== adminPassword) {
Expand All @@ -244,7 +265,7 @@ const server = http
return
}

Util.post(req, (params) => {
Util.post(req, res, (params) => {
const { token, username } = params

if (token !== adminPassword) {
Expand Down Expand Up @@ -275,7 +296,7 @@ const server = http
return
}

Util.post(req, (params) => {
Util.post(req, res, (params) => {
const { token, username } = params

if (token !== adminPassword) {
Expand All @@ -291,6 +312,10 @@ const server = http
API.getContributorAvatar(username).then((result) => {
if (result === '') {
res.end(JSON.stringify({ message: 'Not found' }))
} else if (!result) {
res.end(JSON.stringify({
message: 'Unable to fetch contributor profile. Check GitHub token and network connectivity.',
}))
} else {
// Add this contributor in config.json
Config.contributors.unshift(username)
Expand Down Expand Up @@ -336,8 +361,7 @@ const server = http
return
}

jsonfile.readFile(dataPath, async (err, obj) => {
if (err) console.log('[ERROR]' + err)
readJsonFileOr500(res, dataPath, async (obj) => {
res.end(JSON.stringify(await API.getStats(obj)))
})
break
Expand All @@ -347,8 +371,7 @@ const server = http
return
}

jsonfile.readFile(dataPath, async (err, obj) => {
if (err) console.log('[ERROR]' + err)
readJsonFileOr500(res, dataPath, async (obj) => {
const query = url.parse(req.url, true).query

// Gets list of contributors sorted by parameter if provided
Expand Down Expand Up @@ -385,8 +408,7 @@ const server = http
return
}

jsonfile.readFile(dataPath, async (err, obj) => {
if (err) console.log('[ERROR]' + err)
readJsonFileOr500(res, dataPath, async (obj) => {
const query = url.parse(req.url, true).query

if (query.username) {
Expand Down
23 changes: 17 additions & 6 deletions src/server/refresh.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,9 @@ if (fs.existsSync(logPath)) {

async function getAllContributorsInfo() {
let Config = jsonfile.readFileSync(configPath)
let contributors = Config.contributors
let includedRepositories = Config.includedRepositories
let organization = process.env.ORGANIZATION || Config.organization
let contributors = Array.isArray(Config.contributors) ? Config.contributors : []
let includedRepositories = Array.isArray(Config.includedRepositories) ? Config.includedRepositories : []
let startDate = Config.startDate

interval = contributors.length < 150 ? 150 : (contributors.length + 10) // update interval
Expand All @@ -41,11 +42,17 @@ async function getAllContributorsInfo() {

await Promise.delay(delay * 1000)

API.getContributorInfo(process.env.ORGANIZATION, contributor, includedRepositories, startDate).then( res => {
try {
const res = await API.getContributorInfo(
organization,
contributor,
includedRepositories,
startDate
)
Config = jsonfile.readFileSync(configPath) // update Config
delay = Config.delay // update delay

if (res.avatarUrl !== '' && res.issuesNumber !== -1 && res.mergedPRsNumber !== -1 && res.openPRsNumber != -1) {
if (res && res.avatarUrl !== '' && res.issuesNumber !== -1 && res.mergedPRsNumber !== -1 && res.openPRsNumber !== -1) {

dataBuffer = jsonfile.readFileSync(dataPath)

Expand All @@ -58,8 +65,12 @@ async function getAllContributorsInfo() {
if (err) console.error(err)
})
}
} else {
console.log(`[WARNING] Skipped update for ${contributor}. Check GitHub token, rate limit, or network status.`)
}
})
} catch (err) {
console.log(`[ERROR] Failed to update ${contributor}: ${err && err.message ? err.message : err}`)
}

// Record time
logBuffer.endtime = Date.now()
Expand All @@ -71,4 +82,4 @@ async function getAllContributorsInfo() {
}

getAllContributorsInfo()
setInterval(getAllContributorsInfo, interval * delay * 1000)
setInterval(getAllContributorsInfo, interval * delay * 1000)
51 changes: 41 additions & 10 deletions src/server/util/API.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,19 @@ const chalk = require('chalk')

const BASEURL = 'https://github.com'
const APIHOST = 'https://api.github.com'
let hasLoggedBadCredentials = false

function getRateLimitResetMessage(headers) {
const resetHeader = headers && headers['x-ratelimit-reset']
if (!resetHeader) {
return ''
}
const resetTime = Number(resetHeader) * 1000
if (!Number.isFinite(resetTime)) {
return ''
}
return ` Retry after ${new Date(resetTime).toISOString()}.`
}

async function get(url, _authToken) {
try {
Expand All @@ -22,25 +35,40 @@ async function get(url, _authToken) {
})
} catch (err) {
if (err.code === 'ECONNABORTED') {
console.log(chalk.yellow('[WARNING] Time Out.'))
console.log(chalk.yellow('[WARNING] GitHub API request timed out.'))
return
}
if (err.response !== undefined) {
const message = err.response.data.message
switch (message) {
case 'Bad credentials':
if (message === 'Bad credentials') {
if (!hasLoggedBadCredentials) {
console.log(
chalk.red(
'[ERROR] GitHub token is invalid or expired. Please update the AUTH_TOKEN env variable and restart the server.'
)
)
hasLoggedBadCredentials = true
}
return
}
if (message && message.indexOf('API rate limit exceeded') === 0) {
console.log(
chalk.red(
'[ERROR] Your GitHub Token is not correct! Please check the AUTH_TOKEN env variable.'
chalk.yellow(
'[WARNING] GitHub API rate limit exceeded.' +
getRateLimitResetMessage(err.response.headers)
)
)
process.exit()
break
default:
console.log(chalk.yellow('[WARNING] ' + message))
return
}
console.log(chalk.yellow('[WARNING] ' + message))
} else {
console.log(err)
console.log(
chalk.yellow(
`[WARNING] GitHub API request failed: ${
err.message || 'Unknown network error'
}`
)
)
}
}
}
Expand Down Expand Up @@ -130,6 +158,9 @@ async function getContributorInfo(
includedRepositories,
startDate
) {
if (!Array.isArray(includedRepositories)) {
includedRepositories = []
}
const home = BASEURL + '/' + contributor
const avatarUrl = await getContributorAvatar(contributor)
let OpenPRsURL = `/search/issues?q=is:pr+author:${contributor}+is:Open+created:>=${startDate}`
Expand Down
5 changes: 4 additions & 1 deletion src/server/util/Util.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
function post(req, callback) {
function post(req, res, callback) {
Comment thread
srijnabhargav marked this conversation as resolved.
if(req.method === 'POST') {
let body = ''

Expand All @@ -10,6 +10,9 @@ function post(req, callback) {
try {
callback(JSON.parse(body))
} catch (ex) {
res.statusCode = 400
res.setHeader('Content-Type', 'application/json')
res.end(JSON.stringify({ message: 'Invalid JSON body' }))
return
}
})
Expand Down
8 changes: 8 additions & 0 deletions test/.eslintrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"env": {
"node": true
},
"globals": {
"test": "readonly"
}
}
17 changes: 11 additions & 6 deletions test/README.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
# Regression tests

This directory holds automated regression tests for stable leaderboard behavior.
This directory holds automated regression tests for server behavior.

`leaderboard-e2e.test.js` starts the current server code against a fixed Rocket.Chat snapshot and verifies that `/stats`, `/rank`, and selected `/contributor` and `/rank?username=` responses still match the checked-in expected output.
Current suites:

Node version used:
- `util-post.test.js` covers invalid JSON handling for admin `POST` bodies and related error-handling behavior.
- `leaderboard-e2e.test.js` starts the current server code against a fixed Rocket.Chat snapshot and verifies that `/stats`, `/rank`, and selected `/contributor` and `/rank?username=` responses still match the checked-in expected output.

Tests are run with Node's built-in test runner (`node --test`). Node.js 18+ is required.

Node version used for the leaderboard regression test:

- Node.js `v25.4.0`

Expand All @@ -13,14 +18,14 @@ Fixtures:
- `../contrib/rocketchat/gsoc/2025/gsoc2025final.json` is the canonical snapshot used as the fixed leaderboard input.
- `fixtures/gsoc2025final.expected.json` is the checked-in golden output generated from the current stable ranking logic and used for regression comparisons.

Run from the repo root:
From the repo root:

```bash
npm i
npm --prefix src/server install
npm test
```

Note: `npm i` at the repo root installs only root dependencies. The regression test boots `src/server/app.js`, so `src/server` dependencies must also be installed before running `npm test`.
Note: `npm i` at the repo root installs only root dependencies. The leaderboard regression test boots `src/server/app.js`, so `src/server` dependencies must also be installed before running `npm test`.

The test itself uses the env/path override support already available in the upstream dotenv-based server setup (`CONFIG_PATH`, `DATA_PATH`, `LOG_PATH`, `ADMINDATA_PATH`, `CONFIG_BACKUP_PATH`, `SERVER_PORT`) and does not require additional source changes under `src/server`.
The leaderboard test uses the env/path override support already available in the upstream dotenv-based server setup (`CONFIG_PATH`, `DATA_PATH`, `LOG_PATH`, `ADMINDATA_PATH`, `CONFIG_BACKUP_PATH`, `SERVER_PORT`) and does not require additional source changes under `src/server`.
9 changes: 8 additions & 1 deletion test/leaderboard-e2e.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -86,9 +86,16 @@ before(async () => {

childProcess.spawn = function (command, args) {
if (command === 'node' && Array.isArray(args) && args[0] === 'refresh.js') {
return {
const mockChild = {
kill() {},
on() {
return mockChild
},
once() {
return mockChild
},
}
return mockChild
}

return originalSpawn.apply(this, arguments)
Expand Down
Loading
Loading